# JavaScript Client

> Typed JS/TS client for the Struere HTTP API

The `struere` npm package ships a typed JavaScript client under the `struere/client` subpath. It wraps the Chat API and Data API with end-to-end TypeScript types, throws structured errors, and runs in browsers, Node 18+, Bun, and Deno with zero runtime dependencies.

## Installation

```bash
bun add struere
```

```ts
import { StruereClient, StruereApiError } from 'struere/client'
```

The `struere/client` subpath has no dependency on the Node-only definition primitives (`defineAgent`, `defineData`, etc.), so it is safe to import from browser bundles.

## Constructor

```ts
const struere = new StruereClient({
  apiKey: process.env.STRUERE_API_KEY!,
})
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `apiKey` | `string` | — | API key for the target environment. Required |
| `baseUrl` | `string` | `'https://api.struere.dev'` | Override the API base URL |
| `fetch` | `typeof globalThis.fetch` | `globalThis.fetch` | Custom fetch implementation for tests or runtimes without a global fetch |

The client throws synchronously on construction if `apiKey` is missing or if no `fetch` is available.

## Works In

- **Browsers** — CORS-enabled (`Access-Control-Allow-Origin: *`) on Chat and Data endpoints, no proxy required
- **Node 18+** — uses the global `fetch`
- **Bun** — uses the global `fetch`
- **Deno** — uses the global `fetch`

## Chat

```ts
const reply = await struere.chat({
  agentSlug: 'support',
  message: 'Hi!',
})

console.log(reply.threadId, reply.message, reply.usage)
```

### `client.chat(request)`

```ts
interface ChatRequest {
  agentSlug?: string
  agentId?: string
  routerSlug?: string
  message: string
  threadId?: string
  externalThreadId?: string
  threadContext?: { params?: Record<string, unknown> }
}
```

Exactly one of `agentSlug`, `agentId`, or `routerSlug` is required. The client routes to:

- `POST /v1/agents/:slug/chat` when `agentSlug` is set
- `POST /v1/routers/:slug/chat` when only `routerSlug` is set
- `POST /v1/chat` when `agentId` is set (with optional `routerSlug` in the body)

```ts
interface ChatResponse {
  threadId: string
  message: string
  assistantMessageId?: string
  usage: {
    inputTokens: number
    outputTokens: number
    totalTokens: number
    reasoningTokens?: number
  }
  _executionMeta?: {
    iterationCount: number
    model: string
    durationMs: number
    toolCallSummary: Array<{ name: string; durationMs: number; status: string; errorType?: string; errorMessage?: string }>
    errorCount: number
    permissionDenialCount: number
  }
  _transferred?: { targetAgentSlug: string; targetAgent: string }
}
```

Pass `threadId` to continue an existing conversation, or `externalThreadId` to deduplicate by an upstream identifier.

## Data

The `client.data` namespace exposes typed CRUD and query operations against the Data API. All methods return promises and throw `StruereApiError` on non-2xx responses.

### `client.data.entityTypes()`

Lists all entity types in the current environment.

```ts
const { data } = await struere.data.entityTypes()
data.forEach((t) => console.log(t.slug, t.name))
```

### `client.data.list(type, options?)`

Paginated list of entities for a given type.

```ts
const players = await struere.data.list<{ name: string }>('player', { limit: 50 })
console.log(players.data, players.cursor, players.hasMore)
```

| Option | Type | Description |
|--------|------|-------------|
| `limit` | `number` | Page size |
| `cursor` | `string` | Pagination cursor returned by a previous call |
| `status` | `string` | Filter by entity status |

### `client.data.get(type, id)`

Fetch a single entity by ID.

```ts
const player = await struere.data.get<{ name: string }>('player', 'e57abc123')
```

### `client.data.create(type, data, options?)`

Create a new entity.

```ts
const created = await struere.data.create('player', { name: 'Mia', team: 'red' })
```

### `client.data.update(type, id, patch, options?)`

Patch an existing entity. Only the fields you pass are updated.

```ts
const updated = await struere.data.update('player', 'e57abc123', { team: 'blue' })
```

### `client.data.delete(type, id)`

Delete an entity by ID.

```ts
await struere.data.delete('player', 'e57abc123')
```

### `client.data.query(type, options?)`

Filtered query with pagination.

```ts
const matches = await struere.data.query('player', {
  filters: { team: { $eq: 'red' }, score: { $gt: 100 } },
  limit: 25,
})
```

| Option | Type | Description |
|--------|------|-------------|
| `filters` | `Record<string, FilterValue>` | Field filters (see operators below) |
| `status` | `string` | Filter by entity status |
| `limit` | `number` | Page size |
| `cursor` | `string` | Pagination cursor |

#### Filter Operators

A `FilterValue` is either a literal value (implicit `$eq`) or one of:

| Operator | Type | Description |
|----------|------|-------------|
| `$eq` | `unknown` | Field equals value |
| `$neq` | `unknown` | Field does not equal value |
| `$in` | `unknown[]` | Field value is one of the array members |
| `$contains` | `unknown` | Field contains substring or array element |
| `$gt` | `unknown` | Greater than |
| `$gte` | `unknown` | Greater than or equal |
| `$lt` | `unknown` | Less than |
| `$lte` | `unknown` | Less than or equal |
| `$exists` | `boolean` | Field is present (`true`) or absent (`false`) |

```ts
const active = await struere.data.query('player', {
  filters: {
    team: { $in: ['red', 'blue'] },
    nickname: { $contains: 'fox' },
    retiredAt: { $exists: false },
  },
})
```

### `client.data.search(type, options)`

Full-text search across the entity type's `searchFields`.

```ts
const results = await struere.data.search<{ name: string }>('player', {
  query: 'mia',
  limit: 10,
})
```

## Errors

All client methods throw `StruereApiError` on non-2xx HTTP responses.

```ts
import { StruereApiError } from 'struere/client'

try {
  await struere.data.get('player', 'missing')
} catch (err) {
  if (err instanceof StruereApiError) {
    console.error(err.status, err.message, err.code, err.requestId)
  } else {
    throw err
  }
}
```

| Field | Type | Description |
|-------|------|-------------|
| `status` | `number` | HTTP status code |
| `message` | `string` | Error message from the server, or `HTTP <status>` if none |
| `code` | `string?` | Machine-readable error code, when provided |
| `requestId` | `string?` | Request identifier for correlating with server logs |
| `details` | `string?` | Additional human-readable detail |
| `body` | `unknown?` | Parsed response body |

### Filter validation errors

The Data API rejects filters that target unsupported top-level fields. Both errors return `400 Bad Request` and surface through `StruereApiError`.

**Unsupported top-level filter field.** Top-level filters must be on indexed columns. Domain fields stored in the JSON payload must be referenced as `data.<fieldName>`.

```ts
try {
  await struere.data.query('session', {
    filters: { teacherId: 'usr_123' },
  })
} catch (err) {
  if (err instanceof StruereApiError) {
    console.error(err.status, err.message)
  }
}
```

```json
{
  "error": "Unsupported filter field 'teacherId'. Queryable top-level fields: matchId. For fields stored in the entity payload, use 'data.teacherId'."
}
```

**Top-level `status` filter rejected.** Top-level `status` is the entity lifecycle column, not your domain status. The error points to `data.status`.

```ts
try {
  await struere.data.query('session', {
    filters: { status: 'scheduled' },
  })
} catch (err) {
  if (err instanceof StruereApiError) {
    console.error(err.status, err.message)
  }
}
```

```json
{
  "error": "Top-level 'status' filter is not supported (it targets the entity lifecycle column). Use filter: { 'data.status': 'scheduled' } for your domain status field."
}
```

The fix in both cases is to prefix the field with `data.`:

```ts
await struere.data.query('session', {
  filters: { 'data.status': 'scheduled', 'data.teacherId': 'usr_123' },
})
```

## Browser Example: Vite + React

A minimal Vite component that lists entities and renders them. Requires a development API key created via `bunx struere keys create --env development` and stored in `VITE_STRUERE_API_KEY`.

```tsx
import { useEffect, useState } from 'react'
import { StruereClient, StruereApiError } from 'struere/client'

const struere = new StruereClient({
  apiKey: import.meta.env.VITE_STRUERE_API_KEY,
})

interface Player {
  name: string
  team: string
}

export function PlayerList() {
  const [players, setPlayers] = useState<Player[]>([])
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    struere.data
      .list<Player>('player', { limit: 25 })
      .then((res) => setPlayers(res.data.map((e) => e.data)))
      .catch((err) => {
        if (err instanceof StruereApiError) {
          setError(`${err.status}: ${err.message}`)
        } else {
          setError(String(err))
        }
      })
  }, [])

  if (error) return <p>Error: {error}</p>

  return (
    <ul>
      {players.map((p, i) => (
        <li key={i}>{p.name} ({p.team})</li>
      ))}
    </ul>
  )
}
```

For chat from a browser, swap `data.list` for `chat()`:

```ts
const reply = await struere.chat({
  agentSlug: 'support',
  message: 'Hi!',
})
```

## See Also

- [API Overview](./overview) — Authentication, base URL, CORS
- [Chat API](./chat) — Raw HTTP request/response for chat
- [Data API](./data) — Raw HTTP request/response for entity CRUD
- [struere keys](../cli/keys) — Create the API key the client uses
