Architecture philosophy
On this page
SotaAgents deliberately separates the platform control plane from the app data plane. The platform knows the user, organization, workspace, installation, selected environment, exact artifact, and granted capabilities. Your backend knows the domain: how to search documents, generate CAD, call a proprietary API, or apply business rules.
Why should an app have its own backend?#
- Independent ownership: ship business logic on your cadence without adding domain code to the SotaAgents core.
- Security boundary: keep proprietary credentials and third-party integrations server-side; accept only signed platform context.
- Data boundary: choose the database, retention, residency, and scaling model the domain requires.
- Operational isolation: an app can scale, fail, and recover without coupling unrelated apps.
Do not ask the browser to send an arbitrary actor, workspace, or environment. Verify the signed Sota request and use the exact context selected by the platform.
How the assistant calls your tool#
Declaring a tool in the manifest is only half the story. This is what the platform does with that declaration at runtime — the part that makes an app more than a hosted web service.
The tool is offered to the model
When a workspace resolves its app surface, every published tool becomes one model-callable function named app_<appId>_<toolName>. Development and Staging insert the environment (app_my-app_stg_example); Production omits it, so the name the model learns in Production stays clean. Characters the model-facing name cannot carry — including the dots in a name like documents.query — become underscores, names longer than 64 characters are truncated with a short hash suffix, and a collision between two apps gets a numeric suffix. The description the model reads is your manifest description followed by App: <appId>. Runtime tool: <name>., and your inputSchema is handed to the model verbatim as the parameter schema. That is why a vague description or a loose schema degrades tool selection immediately: they are the entire basis on which the model decides.
The platform resolves and authorizes the call
Before any request leaves SotaAgents, the arguments are validated against your input schema, and the platform resolves the organization, workspace, installation, environment, and the one exact artifact that applies. A tool that is not published in that artifact, or an app that is not installed for that workspace, is refused here — your backend is never reached.
A signed request reaches your backend
The platform issues a server-to-server HTTP request to the service.baseUrl resolved for the active environment, at your declared path. In Development the same request travels through the sota dev tunnel to your laptop instead. The browser is never in this path, so your backend does not need to be reachable from the public internet by users.
Your backend verifies, works, and answers JSON
Verify the token, do the domain work, return JSON. A non-2xx response, a body that is not valid JSON, or a timeout is treated as a tool failure.
The result re-enters the conversation
The platform streams the tool result into the conversation as the tool's output part, records it, and — if a UI contribution declares surface: tool-view with this tool in toolNames — renders your React module in place of the plain result. The model sees a compact JSON projection of the same result.
The request the platform sends#
The body is an envelope. The model's arguments are nested at body.input, which is why the scaffolded route reads request.body.body.input rather than request.body.
POST https://my-app.example.com/tools/example
content-type: application/json
authorization: Bearer <invocation JWT>
x-sota-core-token: <delegated Core token> # only when the app declares Core tool grants
{
"requestId": "01K...",
"organizationId": "6a66...",
"workspaceId": "6a66...",
"appId": "my-first-project-demo",
"hook": { "type": "tool", "name": "example" },
"body": {
"input": { "message": "hello" },
"context": { "organizationId": "…", "workspaceId": "…", "userId": "…",
"conversationId": "…", "agentId": "…", "toolCallId": "…", "config": {} }
}
}The organizationId, workspaceId, and context fields in the body are conveniences for logging and debugging. Every authorization decision must come from the verified JWT claims, because only those are signed.
The invocation token#
The Authorization header carries a short-lived, EdDSA-signed JWT minted for this one call. Your backend verifies it against the platform's public keys, published at <core origin>/.well-known/jwks.json. The app holds no Sota private key and never mints tokens itself.
| Property | Value |
|---|---|
Algorithm / typ | EdDSA (Ed25519), header type sota-invocation+jwt |
iss | sota/invocation-token |
aud | Your appId — reject a token minted for another app |
| Lifetime | 60 seconds, with 30 seconds of accepted clock skew |
oid / wid | Organization and workspace the call belongs to |
sub | The acting user, when the call has an actor |
iid | Compatibility install identity. Treat it as opaque; do not infer Development/Staging/Production from a prefix. |
ae / aei / ag | Exact App Environment name, environment identity, and Backend Access Generation. The three claims arrive together and bind authorization to one resolved backend. |
aer | Signed exact execution reference. Persisted/replayed work must keep this reference instead of resolving a newer artifact. |
scp | Granted scopes. Current families include tool:<name>, route:prompt:<name>, route:systemPrompt:<name>, event:<name>, route:artifact:<name>, route:resolver:<path>, lifecycle route scopes, and app:http. Require the exact scope presented for that endpoint. |
rid | The requestId from the envelope, for correlating logs |
Enforce the narrowest scope on each route: the /tools/example handler should require tool:example and nothing broader. The scaffolded src/backend/sota-auth.ts shows the complete check — issuer, audience, algorithm, type, expiry, tenant claims, and scope — in one reusable middleware.
What the response has to look like#
Return a normal JSON HTTP response. There is no Sota-specific wrapper to construct.
| Outcome | What the platform does |
|---|---|
| 2xx with a JSON body | Success. The body becomes the tool result. |
| Non-2xx | Failure. If the body is { "code": "…", "message": "…" } — optionally with details and hint — those exact values are carried through instead of a generic message. Return a real status code rather than a success-shaped error body. |
| Body is not valid JSON | Failure with VALIDATION_ERROR. |
| No answer within the deadline | Failure with TIMEOUT. |
outputSchema is not enforced against the live response — it is checked when you publish, where it drives breaking-change detection between versions. Validate your own output at the boundary; the schema is the contract you promise consumers and renderers, not a runtime guard.
Two optional reserved keys let one result serve two audiences. Put the compact conclusion the model needs and the rich payload your renderer needs in the same response, then use _sota.modelOutput to give the model a completely different projection, or _sota.modelProjection.omitKeys to drop renderer-only keys from what the model reads. The model-visible projection is truncated at roughly 32,000 characters, so keep it to conclusions, ids, counts, and citations rather than raw documents.