# AI agents

An **AI agent** is a registered LLM caller — a configured pairing
of a chat-completion tool, a model identifier, a system prompt
template, and JSON Schemas declaring the agent's input shape and
the LLM's required output shape. Workflows attach agents at
their enrichment stage; the agent's structured output then feeds
the workflow's decision template.

SDK namespace: `api.aiAgents`.

AI agents are **Datalake-DB-resident** — the parent datalake
must be `status: 'ready'` and the bound tool must exist before
you `create()`.

## 1. Wire shape

```typescript
import type {
  AiAgentRequestWritable,
  AiAgentResponse,
} from '@alvera-ai/platform-sdk'

const { data: created } = await api.aiAgents.create(
  tenantSlug,
  datalakeSlug,
  {
    name: 'Contact Us Triage Categorizer',
    description: 'Triages inbound submissions into appointment / job / spam',

    tool_id: chatCompletionToolId,                  // see §2 — must be a tool with intent: 'llm_enrichment'

    model: 'qwen3-vl:8b-instruct',                   // provider-specific model identifier
    data_access: 'tokenized',                        // 'raw' | 'tokenized' (compliance gate)
    temperature: 0.0,
    max_tokens: 2048,
    enabled: true,

    input_schema: {                                  // JSON Schema — fields the prompt receives
      type: 'object',
      properties: {
        msg:           { type: 'string' },
        submission_id: { type: 'string' },
      },
      required: ['msg', 'submission_id'],
    },

    llm_response_schema: {                           // JSON Schema — what the LLM MUST return
      type: 'object',
      properties: {
        category: {
          type: 'string',
          enum: ['appointment_request', 'job_inquiry', 'flag_spam'],
        },
      },
      required: ['category'],
      additionalProperties: false,
    },

    prompt_config: {
      type: 'custom',
      body: `You are a triage assistant. Categorize this submission ...

Submission ID: {{ submission_id }}
Message: {{ msg }}

Respond with JSON: {"category": "..."}`,
    },
  },
)
// created.id, created.slug    — server-derived
// created.name === body.name
```

## 2. Rules the type cannot encode

### `tool_id` must point to a chat-completion tool

The bound tool's `intent` MUST be `llm_enrichment`. Tools with
any other intent (`sms`, `data_exchange`, `status_poller`, …)
are rejected at create time as a 422 on `/tool_id`.

The chat-completion tool is typically a `tool_body_type:
'rest_api'` configured against an OpenAI-compatible endpoint
(OpenAI itself, Ollama, vLLM, etc.). The platform appends
`/chat/completions` to the tool's `base_url`, so:

```
   tool.base_url = 'http://localhost:11434/v1'
   →   actual request URL = 'http://localhost:11434/v1/chat/completions'
```

A tool whose `base_url` already contains `/chat/completions`
will produce a doubled path and 404.

### `llm_response_schema` is REQUIRED and load-bearing

The schema is mandatory at create time — a body omitting it
returns a 422 on `/llm_response_schema`. The schema serves two
roles:

1. **Provider-side decoding constraint**: the platform forwards
   the schema in the chat-completion request as
   `response_format: json_schema`, instructing the provider to
   constrain decoding so the LLM cannot return prose, markdown
   code fences, or chat preamble around the JSON. Without this
   constraint, small models often wrap output in fences and the
   platform's JSON parse step fails with `json_decode_failed`.
2. **Output binding**: the parsed LLM output is exposed in the
   workflow's `additional_context` under the agent's slug, with
   property paths matching the schema (see §3 below).

### Prompt receives Liquid-rendered fields from `input_schema`

The agent's `prompt_config.body` is a Liquid template that
references the agent's `input_schema` fields via `{{ <field> }}`.
At workflow runtime, the workflow's `context_mapping_config`
populates these fields from the inbound dataset row (see §3).

The platform validates at create time that every `{{ <field> }}`
referenced by the prompt body resolves to a property declared in
`input_schema`. Drift between the two surfaces as a 422 on
`/prompt_config/body`.

### `data_access` is the compliance gate

```
data_access: 'tokenized'  — the LLM call reads the tokenized lake:
                             each masked column arrives as a stable
                             tok_<hash>. Safe for any LLM, including
                             third-party. The right choice for triage,
                             categorization and lead scoring, where the
                             model reads free text and has no business
                             knowing whose it is.
data_access: 'raw'        — the LLM call receives real values. MUST be
                             paired with an LLM the operator trusts
                             with them (typically on-prem). For work
                             that genuinely needs the value — sanctions
                             screening, manual review, KYC — and for
                             prompts that carry no identity at all, so
                             there is nothing to hold back.

An agent is never pointed at the redacted lake: its placeholders are
unjoinable by design, which makes them useless to a model.
```

**The platform does NOT enforce `data_access` as immutable — a PUT
`update()` can change it today.** The field is cast by the same
changeset that `update()` uses and carries no immutability guard (and
isn't `readOnly` on the wire), so an update can flip `raw ↔
tokenized`, which changes whether the agent sees real or masked
values. **Treat it as immutable by discipline** — a data source
built expecting one tier shouldn't be silently re-tiered under it —
but know the platform won't stop a flip, so guard it in your own
change-control (or recreate the agent) rather than relying on a
rejected update.

### `enabled` gates whether a nested agent runs

At enrichment time the workflow filters its `workflow_ai_agents`
to the enabled ones and runs only those — a `disabled` agent is
silently **skipped**, not run-and-failed. There is no
`ai_agent_disabled` error code. `enabled: false` is the
operator's kill switch: it takes the agent out of the enrichment
set without detaching it from the parent's `*_ai_agents` array.
If every nested agent is disabled, the enrichment stage lands
`{ status: 'skipped' }` (see §7).

### Numeric clamps on `temperature` and `max_tokens`

The platform validates the numeric ranges at create / update time
— BEFORE any chat-completion call to the bound tool. Out-of-range
values surface as field-level rejections, not as runtime
provider errors:

```
   temperature   required number ∈ [0.0, 2.0]    inclusive on both ends
   max_tokens    required integer ∈ [1, 100_000]  inclusive on both ends
```

These bounds are the platform's, not the provider's. Picking a
value the provider doesn't support (e.g. `temperature: 1.5` on
a model that caps at 1.0) surfaces later as a provider error in
the workflow execution log, not at create.

### Name uniqueness is per-datalake

The agent's `name` (and the server-derived `slug`) must be
unique within the **datalake** — the uniqueness scope is
`[slug, datalake_id]`, and a collision is rejected with a 422 on
`/name` carrying "name must be unique within a datalake". This
matches the other datalake-scoped resources (tools, data
sources, interoperability contracts).

The same logical name can therefore be reused across different
datalakes of one tenant. A `slug` is only ever dereferenced
within its own datalake's workflow templates
(`additional_context.<slug>`), so cross-datalake reuse is safe —
no environment-suffix naming convention is required.

## 3. Workflow & contract nesting

An agent runs as an **enrichment** step on a workflow or an
interoperability contract. The agent is **nested directly on the
parent body** — workflow and interoperability-contract
create/update bodies accept a `workflow_ai_agents` /
`interoperability_contract_ai_agents` array. There is
no separate attach/detach endpoint, and **no per-join checksum**: the
nested agents fold into the parent's own checksum.

```typescript
// Nest on create. `position` is REQUIRED.
const { data: workflow } = await api.workflows.create(
  tenantSlug, datalakeSlug,
  {
    name: 'Contact-Us Triage',
    description: 'Triages inbound contact-us messages.',
    dataset_type: 'generic_table' as const,
    status: 'live' as const,
    tags: [],
    workflow_ai_agents: [
      {
        ai_agent_id: createdAgentId,
        position: 0,                              // ordering when multiple agents fire; REQUIRED
        context_mapping_config: {
          type: 'custom',
          // Liquid renders to a JSON object whose keys match the agent's
          // input_schema. Values reference the inbound row via `event_dataset.<field>`.
          body: JSON.stringify({
            msg:           '{{ event_dataset.message }}',
            submission_id: '{{ event_dataset.submission_id }}',
          }),
          // Workflow joins OMIT output_schema — the server pins it from
          // the agent's input_schema. Interop joins author it (below).
        },
      },
    ],
  },
)
workflow.checksum   // the PARENT checksum — the nested agents fold into it
```

Interoperability contracts nest the same way, via the
`interoperability_contract_ai_agents` array on the contract
create/update body. The one difference: a contract join's
`context_mapping_config` **does** carry `output_schema` (the server
does not pin it there).

Adding, repositioning, and removing agents are all expressed by
re-supplying the full set on the parent's `update()` — `update()`
replaces the whole array, and any item you omit is removed:

| Operation  | How                                                  | Result |
|------------|------------------------------------------------------|--------|
| add / nest | include in `*_ai_agents` on create or update         | parent checksum reflects the agents |
| reposition | `update()` with the same agent at a new `position`   | full-set replace |
| detach     | `update()` omitting that agent (or `[]` to clear all)| the omitted agent is removed |
| drift      | parent `checksum()` endpoint over the full body      | the would-be parent checksum (no persist) |

The parent surfaces its nested agents on read: a workflow `get()`
returns `workflow_ai_agents: [...]` and a contract `get()` returns
`interoperability_contract_ai_agents: [...]`. Both arrays are writable
on the parent body (create + update).

The workflow's own `decision_config` reads the agent's parsed output
at `additional_context.<agent_slug>.<field>` (property paths match the
agent's `llm_response_schema`):

```typescript
decision_config: {
  type: 'custom',
  body: '["{{ additional_context.contact-us-triage-categorizer.category }}"]',
  output_schema: '{"type":"array","items":{"type":"string"}}',
}
```

The flow at run time:

```
   inbound row
       │
       ▼
   workflow filter
       │
       ▼
   for each ai_agents[i] (ordered by position):
     • render context_mapping_config.body with Liquid (event_dataset.*, etc.)
     • parse rendered output as JSON
     • check it conforms to agent.input_schema
     • forward to the bound tool as a chat completion
     • parse the LLM response per agent.llm_response_schema
     • merge under additional_context[agent.slug]
       │
       ▼
   workflow decision (reads additional_context.<agent_slug>.<field>)
       │
       ▼
   workflow action(s)
```

## 4. Field ownership

**Server-derived (Response-only).** Universal set from
`type_naming.md`. The `slug` is what workflow templates
reference via `additional_context.<slug>`.

**Caller-supplied (round-trip).**

```
name                  required string
description           optional string
tool_id               required UUID  — must be intent: llm_enrichment
model                 required string — provider-specific identifier
data_access           required enum  — 'raw' | 'tokenized' (no default)
temperature           required number
max_tokens            required number
enabled               required bool
input_schema          required JSON Schema object
llm_response_schema   required JSON Schema object  — see §2
prompt_config         required embed — { type, body }
```

**Write-only (Request-only).** None.

## 5. Error envelopes

Standard JSON:API envelopes per `errors.md`. Common rejections:

| `source.pointer`                | Cause                                              |
|---------------------------------|----------------------------------------------------|
| `/tool_id`                      | tool's intent isn't `llm_enrichment`              |
| `/llm_response_schema`          | missing or not a valid JSON Schema object          |
| `/input_schema`                 | missing or not a valid JSON Schema object          |
| `/prompt_config/body`           | references a field absent from `input_schema`      |
| `/data_access`                  | value not in enum                                  |
| `/temperature`                  | outside the inclusive range [0.0, 2.0]             |
| `/max_tokens`                   | outside the inclusive range [1, 100_000]           |
| `/name`                         | name/slug collision — unique within the datalake (`[slug, datalake_id]`) |

## 6. Lifecycle

### Create

Synchronous. The agent is immediately invocable by workflows
once `enabled: true`.

### Read shapes

```
.list(tenantSlug, datalakeSlug, query?)      Paged: { data, meta }
                                              (full Flop query — type_naming.md)
.get(tenantSlug, datalakeSlug, id)           One row, BY ID (UUID — not slug)
.metadata(tenantSlug, datalakeSlug)          Markdown catalog
.metadataDetails(tenantSlug, datalakeSlug,   Markdown for one agent;
                  id)                         section headings: Wrapper,
                                              Model, Bound tool, Prompt
                                              config, Input schema, LLM
                                              response schema
```

`.get` and `.metadataDetails` accept the agent's UUID `id` as
the path key — NOT the slug. The slug is only used as a
runtime reference key (`additional_context.<slug>` in workflow
templates).

### Update

`update()` replaces the whole resource — resupply the full
body; there's no partial update. That means all required
fields must be re-supplied verbatim, including
`input_schema`, `llm_response_schema`, and `prompt_config`.
Omitting any returns a 422.

### Delete

`delete()` removes the row — but only if nothing references it.
The delete is **blocked** (rejected as a 422) while the agent is
still nested on any workflow (`workflow_ai_agents`) or
interoperability contract (`interoperability_contract_ai_agents`),
or while it has invocation `usage_logs`. Detach it from every
parent first (re-supply the parent's `*_ai_agents` array without
it — see §3), then delete. There is no "referenced agent silently
deleted, workflow fails later" path: the association constraint
stops the delete up front, so a live workflow never loses an agent
out from under it.

### Invoke (direct, outside a workflow)

`invoke()` runs the agent once, synchronously, against a
caller-supplied input map — the direct path for reading an
uploaded file or doing ad-hoc extraction without nesting the
agent on a workflow or contract.

```
.invoke(tenantSlug, datalakeSlug, id, body)   POST .../ai-agents/:id/invoke
```

The body is `AiAgentInvokeRequest`:

```
input   required object  — the prompt vars; validated against the
                           agent's input_schema BEFORE the model is
                           called (a violation is a 422)
files   optional array   — [{ key, content_type }] attachments for
                           multimodal (vision) invocation. `key` is a
                           storage key from datalakes.createUploadLink;
                           content_type ∈ application/pdf | image/png |
                           image/jpeg | image/webp. The platform
                           downloads each file from the agent's datalake,
                           Base64-encodes it, and hands it to the model
                           as a content part.
```

The response (`AiAgentInvokeResponse`):

```
output       object  — the parsed LLM JSON, matching llm_response_schema
explanation  string? — the model's reasoning trace, when the tool's
                       response_extractor mapped one out; null otherwise
usage        object  — { model, input_tokens, output_tokens, total_tokens,
                       latency_ms } telemetry for the call
```

Invoke passes NO `context_mapping_config` — `input` is validated
directly against `input_schema` (unlike the nested workflow path,
which remaps host context first). See `cookbook/primary-care.md`
§025–028 for the full upload → tool → agent → invoke walk, including
the input-gate refusal.

### Drift checksum

`checksum()` computes the SHA-256 drift fingerprint a
create/update WOULD stamp for a given body, without persisting —
the stateless plan-time primitive `alvera plan` uses.

```
.checksum(tenantSlug, datalakeSlug, body)   POST .../ai-agents/checksum
```

Compare the result against a deployed agent's server-computed
`checksum` (from `get()`) to detect config drift. The fingerprint
covers the authored fields only — `name`, `model`, `data_access`,
`enabled`, `temperature`, `max_tokens`, `tool_id`, `description`,
`input_schema`, `llm_response_schema`, `prompt_config` — server
bookkeeping (`slug`, `id`, timestamps, FKs) is excluded.

## 7. Run-time failure modes

When a workflow run's enrichment stage fails, the per-execution
`error.json` artifact carries a structured failure record (see
`workflows.md` §6 for the artifact convention). The
agent-specific codes:

```
tool_execution_failed        — the bound chat-completion tool's
                                HTTP call failed (non-2xx, refused,
                                timeout). Returned as a rich
                                {code, message}: error.json's `detail`
                                carries the URL + status; the workflow
                                execution log carries a coarse
                                error_message of the form
                                "AI enrichment failed: <agent_name>"
json_decode_failed           — the model's content didn't parse as
                                JSON (typically llm_response_schema
                                wasn't wired through to the provider's
                                structured-output mode, so the model
                                wrapped JSON in prose / code fences)
response_extraction_failed   — the tool's response_extractor template
                                failed to render/decode the provider
                                envelope, or its output violated the
                                extractor's own output_schema
context_mapping_failed       — the nested join's
                                context_mapping_config.body failed to
                                render or JSON-decode
context_mapping_not_a_map    — context_mapping_config rendered valid
                                JSON that wasn't an object
input_schema_validation_failed — the effective prompt vars (post
                                context-mapping) didn't validate
                                against the agent's input_schema
tool_call_type_not_supported_for_llm_enrichment
                             — the bound tool's body can't host a model
                                call (only rest_api and aws_lambda
                                bodies can)
```

There is no `ai_agent_disabled` or `ai_agent_not_found` runtime
code: a disabled agent is silently filtered out of the enrichment
set and never runs (§2), and an agent still nested on a
workflow/contract cannot be deleted in the first place (§6, Delete).

The `error.json` shape:

```
{
  error_code:    string  — one of the codes above
  stage:         string  — 'enrichment' for agent-related failures
  ai_agent_slug: string  — slug of the failing agent
  error_message: string  — coarse copy of the workflow execution log's message
  detail:        string  — rich provider context (URL, HTTP status, etc.);
                            ONLY present in error.json, NOT mirrored to the
                            workflow execution log row
}
```

The `enrichment.json` artifact (success branch) carries the
parsed agent outputs:

```
{
  status: 'completed' | 'failed' | 'skipped',
  <agent_slug_1>: {
    status: 'completed',
    output: { /* matches llm_response_schema */ },
  },
  <agent_slug_2>: { /* ... */ },
}
```

For workflows without any `ai_agents`, `enrichment.json` lands
with `{ status: 'skipped' }` and no per-agent entries.

## 8. Gotchas

1. **`llm_response_schema` is REQUIRED**, not optional. Omitting
   it returns 422 on `/llm_response_schema`. The platform uses
   it both as a provider-side decoding constraint and as the
   output-binding shape for `additional_context.<slug>`. Missing
   the schema means no structured output and downstream decision
   templates can't reference the agent's fields.

2. **`tool_id` must reference an `intent: 'llm_enrichment'`
   tool.** Other intents are rejected at create. The chat
   completion tool is typically `tool_body_type: 'rest_api'`
   against an OpenAI-compatible endpoint; the platform appends
   `/chat/completions` to its `base_url`.

3. **`additional_context.<slug>` is the workflow accessor.**
   The agent's `slug` (server-derived from `name`) is what
   workflow decision and action templates reference. Capture it
   from the create response — don't pre-compute.

4. **`.get` takes UUID `id`, not slug.** The single-row read
   route is `/ai-agents/:id`. The slug is only a runtime template key.

5. **`update()` replaces the whole resource.** Update bodies
   must re-supply `input_schema`, `llm_response_schema`,
   `prompt_config` verbatim — there's no partial update. A
   partial body silently drops fields and returns 422 on the
   missing required field.

6. **`data_access` is immutable by convention only — the platform
   won't stop a PUT from flipping it.** There's no immutability
   guard in the changeset, so an `update()` can change the
   compliance tier (`raw ↔ tokenized`) and re-tier what the
   agent sees. Enforce it in your own change-control (or delete +
   recreate); don't rely on the platform to reject the flip (§2).

7. **`prompt_config.body` field references must resolve in
   `input_schema`.** A `{{ field }}` that isn't declared in
   `input_schema` returns 422 on `/prompt_config/body` at
   create time.

8. **The chat-completion tool's `base_url` should NOT include
   `/chat/completions`.** The platform appends the path; a
   doubled path 404s. Use the API root (e.g. `/v1` for OpenAI
   or Ollama).
