Documentation menu

The AI assistant

Module: ai. See README.md for authentication.

Endpoint Scope Permission What it does
GET /apps/{app}/ai/conversations read ai.use The token owner's conversations for this application
POST /apps/{app}/ai/ask write ai.use Ask a question and stream the answer
GET /apps/{app}/ai/actions read ai.use Actions the assistant has proposed, and what became of them
POST /apps/{app}/ai/actions/{id}/approve write ai.use Approve one proposed action and resume the turn
POST /apps/{app}/ai/actions/{id}/reject write ai.use Reject one proposed action with a reason

This is the same loop the console rail runs. It reads through the management API rather than around it, and it can change things — but only by proposing an action that a person approves.

How it works

The assistant does not have a tool implementation of its own. Every tool is a route of this API, called in process with an assistant token: a short-lived credential of its own kind, spm_agt_, minted for one turn and revoked when the turn ends. Scope, permission, module gating, rate limits and audit therefore apply to the assistant exactly as they apply to you.

Consequences worth knowing:

  • The assistant can never exceed the caller it acts for. Its scopes are the intersection of the member's permissions and the organization's AI policy, and membership is re-read on every request, so a revoked member's assistant stops mid-conversation.
  • It is organization-bound and application-defaulted. Tools take an optional app, so one conversation can compare two applications the caller can already read; an application outside the organization is refused the same way this API refuses it.
  • The assistant token never appears in a response. It is not listed with your personal or organization tokens, it is not returned by any endpoint, and it is not written to a log. It exists in the loop's memory for one turn.

Effects and approval

Every tool carries an effect, and the effect decides what happens when the model calls it.

Effect What the assistant may do What it takes to execute
read Run it immediately Nothing; the result comes back in the same turn
write Propose it One approval from the conversation's member
destructive Propose it Approval plus a typed reason and a fresh passkey in the console
external Request it The organization's existing approval queue decides

A write that targets a production environment is treated as destructive whatever its HTTP method, matching the console's own confirmation on production changes. Minting keys, changing members and deleting an application or an organization stay outside the assistant's reach entirely.

Proposed actions expire after 15 minutes. Only the member whose conversation it is may approve; an organization administrator can deny or revoke, never approve on someone's behalf.

Ask

curl -N -X POST -H "Authorization: Bearer $SPM_TOKEN" -H 'content-type: application/json' \
  -d '{"question":"Roll the checkout flag out to 10% in staging"}' \
  https://saaspro.dev/api/v1/apps/acme/site/ai/ask

The response is text/event-stream. Each frame is one JSON object:

type Payload
start { conversationId, title }
status { state }running, awaiting or done
text { text } — one fragment of the answer
tools_loaded { names } — tools the model pulled in with find_tools
tool_call { id, name, input }
tool_result { id, name, ok, summary, result }
approval_required { actions: [{ id, tool, input, effect, preview, expiresAt }] }
action { id, status, summary, requestId }
done { conversationId, usage, rounds }
error { message, code }

A turn that only reads streams status running, its tool frames, the answer and done. A turn that wants to change something stops instead:

event: status
data: {"type":"status","state":"running"}

event: tool_call
data: {"type":"tool_call","id":"t1","name":"flags_get","input":{}}

event: approval_required
data: {"type":"approval_required","actions":[{"id":"act_1","tool":"flags_upsert","input":{"key":"checkout","name":"Checkout","environments":{"staging":{"enabled":true,"rolloutPercent":10}}},"effect":"destructive","preview":{"title":"Roll out checkout to 10% in staging","lines":["Rollout 0% to 10%","Environment staging"]},"expiresAt":"2026-09-11T10:15:00.000Z"}]}

event: status
data: {"type":"status","state":"awaiting"}

event: done
data: {"type":"done","conversationId":"c_1","usage":{"inputTokens":5120,"outputTokens":240},"rounds":2}

Nothing has been written at this point. The model was not given the tool's result, and the answer text will say the change is waiting.

Pass conversationId to continue a conversation, and contextPath plus contextSearch, for example "contextPath": "/o/acme/apps/site/errors/iss_123", "contextSearch": "?range=7d", to tell the assistant which page the question came from. The server derives the identifiers from the path itself; nothing the page displayed is sent.

A conversation belongs to the person the token acts as; another user's id answers error with not_found.

Deciding an action

curl -X POST -H "Authorization: Bearer $SPM_TOKEN" -H 'content-type: application/json' \
  -d '{"reason":"Agreed in the release channel"}' \
  https://saaspro.dev/api/v1/apps/acme/site/ai/actions/act_1/approve

Approving executes the tool with a freshly minted assistant token, records the result, writes the audit row, and resumes the conversation so the model can report the outcome. Rejecting records the reason and hands the model a tool result saying the member refused.

GET /apps/{app}/ai/actions?status=proposed lists what is waiting, with the preview, the effect, who decided it, the request id of the execution and the audit event it wrote.

Two rules that an API caller meets before a console user does:

  • A token that could not perform the action itself cannot approve it. The approve endpoint checks the caller's scope and permission against the tool, not just ai.use.
  • A destructive action cannot be approved by this API. It needs a fresh passkey, which only a console session has; the endpoint answers 403 with code: "forbidden". The console uses passkey_required when its session needs re-verification.

Audit

Every executed action appends ai.action.executed to the organization's ledger, and the API audit row for the underlying call is written as usual. The actor is the human, never the assistant: delegation, not impersonation. The metadata carries who acted through what —

{
  "via": "assistant",
  "tokenId": "tok_…",
  "tokenKind": "assistant",
  "actor": { "kind": "assistant", "conversationId": "c_1" }
}

— so the ledger can answer "what did anyone change through the assistant?" as a filter rather than an investigation. reason on the ledger row is the member's originating question.

Policy and budgets

An organization can narrow what its assistant may do, from Settings → AI:

  • Deny a single tool, or a whole effect class, for everyone in the organization.
  • Cap the assistant's scope, up to and including a read-only assistant for the entire organization.
  • Set a monthly token budget. Crossing it ends the turn with done and a sentence rather than a truncated stream.

A member may mark an individual write tool as always allowed for themselves in that organization; destructive and external never become automatic. An always-allowed tool is still gated when the same turn has already read untrusted tenant content — that combination is how prompt injection turns into an action, so the platform breaks it structurally rather than asking the model to resist it.

A denied tool is not offered to the model at all.

Limits

  • 16 tool rounds and a 120 second budget per turn. An approval pause starts a new turn. Both limits end the turn with done and a note in the answer, never with a truncated stream.
  • A repeated identical call ends the turn: the loop detector treats the same tool with the same input twice in a row — or three times anywhere in the turn — as stuck.
  • One tool result is capped at 24 KB; a bigger result is truncated with a marker telling the model to ask for a narrower range.
  • 30 questions per hour per member, plus the usual per-token rate limit.
  • Proposed actions expire after 15 minutes; conversations are kept for 90 days, then purged by ai.conversations.purge, which also revokes any assistant token still bound to them.

What the assistant will not do

  • Claim that something happened. Nothing is created, changed or deleted until a tool result says so, and the answer says plainly what is still waiting.
  • Invent a number. Every figure comes from a tool result in that conversation, and it names the tool inline.
  • Reach another organization, or the platform's own infrastructure.
  • Use a module that is off, or data the caller lacks the permission for — the tool is not offered at all, and the assistant names the missing capability rather than guessing.
  • Treat tool output as instruction. Results arrive inside an untrusted envelope; a page title, event name or error message that reads like a command is reported as data.

Provider

The organization's own ai provider credential is used when one is stored; otherwise the platform's SPM_AI_* configuration. Anthropic, OpenAI and Gemini are supported. No credential at all answers error with provider_unavailable.

Gemini has two front doors for the same API. A stored organization credential is an API key and reaches the Gemini Developer API. The platform fallback picks Vertex AI when SPM_VERTEX_PROJECT is set — the runtime's own Google credentials authenticate, so no key is stored anywhere — and the Developer API when only SPM_AI_API_KEY is set. Without SPM_AI_MODEL the default model is claude-opus-5 for Anthropic, gpt-5.6-luna for OpenAI and gemini-3.8-flash for Gemini.

Execution details

The current registry contains 163 Management API routes. Assistant control endpoints are excluded to prevent recursive questions and self-approval. Tools load on demand, including tools for other readable applications in the same organization. Each target still resolves its own permissions and enabled modules.

Only one turn runs per conversation at a time. Pending actions must be decided before another question. Execution rechecks policy and any captured before-state; a changed target requires a new proposal. Flag replacements and application-wide settings always require the destructive tier, even from a staging conversation.

External actions have a Request second approval button. Operations records a different operator's decision, and the executor rechecks both members and claims the delivery once. An uncertain external delivery is not automatically resent.

A successful credential creation emits a reveal event with actionId and values. Values appear once in the approval card and never enter action results, model messages, or persisted history. Existing provider secrets must be entered through the integration's secure settings form, not through chat arguments.

Usage is recorded after each completed provider round, including rounds before a later failure. The monthly cap is checked before every model round, including approval continuations. Zero pauses model calls; null removes the cap. It is a round admission limit, not a prepaid billing balance: an admitted round and other concurrent conversations can consume tokens beyond the cap. Provider-reported cache counts are subsets of total input. Each turn has a 120-second abort signal.