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
destructiveaction cannot be approved by this API. It needs a fresh passkey, which only a console session has; the endpoint answers403withcode: "forbidden". The console usespasskey_requiredwhen 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
doneand 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
doneand 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.