# Using AI from a plugin

> The $app.ai capability surface a plugin uses to generate, reason, retrieve and delegate.

_Updated: 2026-07-21_

A plugin reaches AI through one binding: `$app.ai`. You ask for a **capability** — generate this, embed that, search my
knowledge — and the core resolves which provider and model serves it. A plugin never names a provider, a model or a
backend, and never passes a tenant: tenant and identity come from the call context. The binding is **always present**;
with no compute configured the methods still exist and throw a typed error. `status()` never throws:

```js
const s = $app.ai.status();  // { provider_configured, has_embedding } + generation_model / embedding_model
if (!s.provider_configured) return { ok: false, reason: "ai_unavailable" };
```

## Generation

```js
$app.ai.generate({ task: "ticket.summarize", input: body, system: "Two sentences.", max_tokens: 300 });
// { text, model_label, usage:{input_tokens,output_tokens,total_tokens}, finish_reason, truncated }
$app.ai.generateJSON({ task: "ticket.classify", input: body, schema });  // { data, model_label, usage, repaired }
$app.ai.embed({ task: "index", inputs: [a, b] });                        // { vectors, dims, model_label, usage }
```

A generation request (`generate`, `generateJSON`, `enqueue`) may carry `sensitivity: "normal" | "confidential" |
"restricted"`, which policy uses when deciding whether the call may leave the machine. Text sent to an **external**
provider is redacted before it leaves the process; local backends see your bytes unchanged. `embed` needs an
embeddings backend — a deployment whose only provider is Anthropic has none, and it throws `no-embedding-backend`.

A synchronous generate is bounded by the verb's own deadline, so queue anything slow. Provider, policy and
rate-limit failures surface at `enqueue`; a run failure lands on the task row, not on your caller.

```js
const t = $app.ai.enqueue({ task: "report.draft", input: text });  // { task_id, status:"queued" }
$app.ai.task(t.task_id);  // { id, status:"queued"|"running"|"completed"|"failed", result?, error? }
```

## Retrieval

```js
$app.ai.searchDocs({ query, top_k: 5 });  // { chunks:[{source,title,text,score,plugin}], total }
$app.ai.askDocs({ question });            // { answer, grounded, citations:[{source,title,plugin,score}], model_label? }
$app.ai.searchKnowledge({ query, collections: ["invoices"] });  // { chunks:[{record_ref,text,score,collection,tenant_scoped}], total }
```

`searchDocs` and `askDocs` retrieve over the **documentation** corpus — core's docs plus the docs your bundle
manifest declared — scoped to the site audience and the caller's tenant. `askDocs` answers with citations, and when
nothing matches it refuses (`grounded: false`) **without calling the model at all**. `searchKnowledge` is RAG over
**tenant records** indexed into the knowledge store (ingest is host-side, not a plugin call); that scope is applied
core-side and is not negotiable — the caller's tenant, only chunks attributed to your plugin.

For durable state there is structured memory: `$app.ai.remember({class, kind, scope, key, content, trust, ttl_seconds})`,
`$app.ai.recall({class, scope, kind, limit})` → `{records, total}`, and `$app.ai.forget(id)`. `class` is `session`,
`task`, `project`, `user`, `semantic`, `episodic` or `policy`; `kind` is `fact` (the default), `preference`,
`hypothesis` or `decision` — so a guess need never be stored as a settled fact.

## Getting a second opinion

Four distinct shapes, not one knob. **`consult`** runs a council over a proposal — primary, critic, safety critic,
judge, plus an adversary when you pass `red_team: true`:

```js
$app.ai.consult({ question, proposal: draft, policy: "high_risk_review", red_team: true });
// { deliberation_id, state, summary, proposal, must_verify, blocking_concerns, verifier, roles, usage }
```

`state` is one of `agreed_verified`, `agreed_unverified`, `disagreed_resolved`, `disagreed_unresolved`, `blocked_risk`,
`needs_more_evidence` — surface it verbatim. The full role transcript comes back (the disagreements are the valuable
part), and the judge never declares truth.

> [!NOTE]
> No deterministic verifier is injected on the plugin path, so outcomes come back honestly unverified rather than
> falsely confirmed. `agreed_unverified` is not a verified result and must not be shown to a user as one.

**`consultPolicy`** answers "must I deliberate at all?" — a pure decision, never a model call. **`selfConsistency`**
takes the `generate` request shape plus `samples` (clamped 2–8, default 3) and returns the majority answer with how
strongly they agreed. **`propose`** diverges instead, generating alternatives from different angles (`n` 2–4, default 3):

```js
$app.ai.consultPolicy({ risk: "high", impact: "money", side_effects: "business_write" });  // { required, roles, mode, human_approval, reasons }
$app.ai.selfConsistency({ input: q, samples: 5 });  // { answer, consistency, agreement, samples, model_label, usage }
$app.ai.propose({ question, n: 3 });                // { question, proposals:[{angle,text,tradeoffs}], usage }
```

## Agent actions

The agent plans over the actions **your manifest declared** — resolved core-side from your slug, never passed at
call time — and executes one through the real verb pipeline under its own `_ai_agents` principal, so auth, RBAC and
audit still apply:

```js
$app.ai.plan({ goal: "close out the stale tickets" });
// { plan:[{step,action_name,verb,autonomy,input,requires_confirmation,rbac,reason}], needs_confirmation, rationale, dropped? }
$app.ai.executeAction({ action: "helpdesk.ticket.close", input: { id }, approval_id });
// { status:"executed"|"waiting_approval"|"denied", http_status?, result?, approval_id?, audit_id? }
```

A step naming an undeclared action is dropped from the plan. An action at a confirmation tier called with neither
`confirm` nor `approval_id` returns `waiting_approval` plus an id; once a human approves it, re-calling with that id
executes — single-use, bound to the exact input reviewed.

## Delegating to another AI peer

`delegate` is a contract, not a free prompt: it needs a `capability` and an `objective`, and it goes to a known
peer. The reply arrives **asynchronously**, so the call returns only the dispatch envelope.

```js
$app.ai.delegate({ capability: "tax.vat_review", objective: "Review this batch for VAT exposure",
                   constraints: { deadline_ms: 30000, no_external_sharing: true } });  // { delegation_id, peer_id, peer_trust, status }
$app.ai.registerPeer({ capabilities: ["tax.vat_review"], name: "Tax review" });  // receiving side
// { agent_id, trust_level, endpoint } — endpoint is the topic ai.delegation.peer.<slug>.inbox
$app.ai.respondDelegation({ delegation_id, status: "completed", claims: [{ claim, confidence }] });
```

Your peer identity is forced to your own slug, and self-registration is clamped to trust level `known` — only an
operator raises it. A reply is taken as **claims**, each graded independently, never as an answer.

## Declaring what you need

The `ai` block in `bundle/manifest.json`:

```json
"ai": {
  "requires": ["generate", "generate_json", "searchKnowledge", "plan", "actions"],
  "external_allowed": false,
  "actions": [{ "name": "helpdesk.ticket.close", "description": "Close a resolved ticket",
                "verb": "POST /api/helpdesk/tickets/close", "permission": "helpdesk.ticket.write",
                "autonomy": "business_write", "idempotent": true }]
}
```

`requires` accepts `generate` (which also covers `enqueue`), `generate_json`, `embed`, `actions` (i.e.
`executeAction`), `searchDocs`, `searchKnowledge` and `plan`. A **non-empty list is a closed set** — one of those
entry points you did not declare is denied with `policy-denied`; the rest of the surface (consult, memory,
delegation) is not gated by `requires`. Omitting the whole `ai` block leaves you ungated by `requires` (operator
policy still applies). `external_allowed` defaults to **false**: every model call is then stamped external-denied and
routed to local backends only. Declaring `true` asks for external egress — it does not grant it, the operator's
policy still decides.

Each action is an autonomy-tagged binding over a verb you already registered, never a new endpoint. Tiers run `read`,
`analysis`, `draft`, `safe_write`, `business_write`, `external_side_effect`, `dangerous`. `business_write` and
`external_side_effect` require confirmation, `dangerous` is never planned or executed, and an optional `impact`
(`money`, `legal`, `security`, `destructive`) makes human sign-off unconditional.

> [!NOTE]
> The block is validated fail-closed at dock time: the verb path must resolve under `/api/<slug>/`, the name must be a
> dotted identifier, autonomy and impact must be known values, and any tier above `analysis` must declare an rbac
> permission listed in `rbac.permissions`. Otherwise the bundle does not install.

## Degrade gracefully

Errors arrive as JS `Error`s whose message starts with a stable code — branch on `e.message`: `no-provider-configured`,
`no-embedding-backend`, `policy-denied`, `rate-limited`, `redaction-blocked`, `backend-unavailable`, `model-load-pending`, `unknown-action`.

```js
try { return { summary: $app.ai.generate({ task: "t", input: body }).text }; }
catch (e) { if (String(e.message).indexOf("no-provider-configured") >= 0) return { summary: null }; throw e; }
```

An operator with no model configured is a normal state, not an error case. Probe with `status()` before offering an AI
feature in your UI and make the non-AI path the default. Memory is softer still: with no memory store configured,
`recall` returns an empty list and `forget` reports `forgotten: false` instead of throwing.
