Using AI from a plugin
The $app.ai capability surface a plugin uses to generate, reason, retrieve and delegate.
Updated
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:
const s = $app.ai.status(); // { provider_configured, has_embedding } + generation_model / embedding_model
if (!s.provider_configured) return { ok: false, reason: "ai_unavailable" };
Generation
$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.
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
$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:
$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):
$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:
$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.
$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:
"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 Errors 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.
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.