Railbase
GPTClaude

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.

Was this page helpful?Thanks for your feedback!