Drive Cloud from a backend
After this page you know exactly which secrets your backend holds, which identifiers it mints, which rows it has to store, and what the request loop looks like when a customer clicks a button in your product and an agent does work in a sandbox.
The shape
Section titled “The shape”Cloud is a server-to-server API. There is one credential type, it is short-lived, and it is derived from a key that never leaves your process.
your product ──▶ your backend ──▶ WAMP Account POST /auth/app-installation-token (browser, (holds the (mints a 10-minute installation token) bot, cron) Ed25519 key) │ ▼ WAMP Cloud /v1/sessions/… ← every call carries that tokenThree properties fall out of that, and they decide your architecture:
- Your client never talks to Cloud. Tokens are per-installation, not per-user; handing one to a browser or a mobile app gives that device the whole customer’s agent authority. Route everything through your backend.
- You mint the identifiers. Sessions, turns, publications and merges all use ids you choose, which is what makes retries safe.
- You own the mapping. WAMP does not index sessions by your user, your ticket, or your tenant. Your database is the index.
Where the signing key lives
Section titled “Where the signing key lives”You generate an Ed25519 keypair once, register the public key with WAMP, and keep the private key in your secret manager. WAMP never sees the private half, so there is no shared secret that support can leak and no credential to rotate by ticket.
import { generateAppKeypair } from '@wamp/app-sdk';
const { privateKeyPem, publicKeyPem, kid } = await generateAppKeypair();// register publicKeyPem with WAMP; store privateKeyPem in your secret manager;// keep kid — it is the key id your signatures carry.@wamp/app-sdk is not published yet — see SDK for whether
the package is installable for you. The same keypair is ten lines of jose,
shown on Authentication, if you cannot wait.
The key is PKCS8 PEM, and kid is its RFC 7638 JWK thumbprint — the same value
WAMP derives when you register the public key.
| Secret | Where it belongs | Rotation |
|---|---|---|
| App private key (PKCS8 PEM) | Secret manager, loaded into memory at boot | Register a second public key, switch kid, then revoke the first |
| Installation token | Memory only. Never a database, never a log | Expires by itself in ten minutes |
Never write an installation token to persistent storage. It is cheaper to mint another one than to own the consequences of storing it.
When to mint tokens
Section titled “When to mint tokens”An installation token lives 600 seconds and is bound to one installation and one audience. You get it by exchanging a short-lived JWT assertion that you sign with your private key.
curl -sS -X POST "$WAMP_ACCOUNT/auth/app-installation-token" \ -H 'Content-Type: application/json' \ -d '{ "assertion": "<your signed JWT>", "installationId": "8a0f1c6b-92d4-4e73-b5a1-0d3e7f2c9b48", "resourceAudience": "wamp-cloud" }'{ "success": true, "token": "eyJhbGciOi…", "expiresIn": 600 }The assertion is a JWT signed EdDSA with your kid in the header, iss and
sub set to your app slug, aud set to the platform’s JWT issuer — the exact
iss claim on any token WAMP has issued you, which is neither the API origin
nor the endpoint path — and a lifetime of at most 600 seconds. A wrong aud
fails with 401 invalid_assertion, exactly like a bad signature. The field name
in the request body is assertion, and the request is validated strictly: the
three keys above plus an optional setupAuthorityId, and nothing else.
The policy that works in practice:
-
Cache per installation. Key the cached token by
installationId. A multi-tenant backend holds one token per active customer, not one globally. -
Refresh on a margin, not on expiry. Re-mint when less than a minute of life remains. Clock skew between your host and WAMP is tolerated only to about 30 seconds.
-
Refresh on
401, once. If a call fails with401 invalid_or_expired_credential, discard the cached token, mint a new one, and retry the request exactly once. Do not loop: a403means the installation lacks a capability, and re-minting will never fix it. -
Do not mint per request. The exchange is rate limited per IP, and a ten-minute token used for ten minutes is the design.
Every Cloud request then carries:
Authorization: Bearer <installation token>That is the only accepted scheme on /v1. Cookies are refused, and sending
X-Wamp-Organization alongside a bearer is 400 organization_header_not_allowed — the token already names exactly one
organization, and letting a header override it would be a tenancy hole.
Mapping your users onto sessions
Section titled “Mapping your users onto sessions”A session is the unit of work. One session per concurrent task, and no multiplexing unrelated work into one conversation — only one run per session may be active at a time, so a shared session serializes everything.
Two mechanisms tie a session back to your world, and you need both.
origin — the back-reference you send
Section titled “origin — the back-reference you send”{ "initialTurn": { "id": "<caller-owned-turn-uuid>", "message": "Fix the failing invoice test and open a PR." }, "model": "<model-id-from-/v1/capabilities>", "origin": { "tenantKey": "acct_9f31", "objectType": "support_ticket", "objectId": "TCK-48213", "endUserId": "u_7c1f9a2e3b8d", "label": "Invoice total is wrong for annual plans", "url": "https://app.example.com/tickets/48213" }}| Field | Required | Constraint |
|---|---|---|
tenantKey |
yes | 1–128 characters. Your customer id, not WAMP’s |
objectType |
yes | 1–64 characters. What kind of thing this session is about |
objectId |
yes | 1–256 characters. Your id for it |
endUserId |
no | 1–128 characters. A stable one-way hash, never raw PII |
label |
no | Up to 256 characters. A human-readable name for the object, for your own surfaces |
url |
no | HTTPS only, up to 2048 characters. Deep link back into your product |
origin is immutable — PATCH accepts only title, model and runtime. Get
it right at creation.
origin travels back to you, not onward to a person: the whole block is returned
on the session resource, and the triple is the filter under
Looking a session up again. The WAMP Cloud web app
does not display it — a human there sees the session’s title, so put anything a
human should read in title, and use label and url to render the crumb in
your product. Do not put an email address, a name, or an account handle in
endUserId; hash your (tenant, user) pair and store the mapping on your side.
Looking a session up again
Section titled “Looking a session up again”GET /v1/sessions filters on the origin triple, so the ticket id you already
have is enough to find its session:
GET /v1/sessions?originTenantKey=acct_9f31&originObjectType=support_ticket&originObjectId=TCK-48213Treat that as recovery, not as your primary path. The session id is still yours to keep: mint it, store it against your object, and address the session directly instead of paging for it.
create table agent_session ( session_id uuid primary key, -- you mint this tenant_id text not null, object_type text not null, object_id text not null, grant_id uuid, -- the repository grant, if any repository_id text, -- required for a grant-backed repository event_cursor bigint not null default 0, -- last event sequence you handled last_run_id uuid, status text not null, -- your own state machine created_at timestamptz not null default now(), unique (tenant_id, object_type, object_id));That unique constraint is the useful part: it makes “one session per ticket” a
database invariant rather than a convention, and it gives you the natural place
to derive a deterministic session id if you would rather not store one.
What to store, and what not to
Section titled “What to store, and what not to”| Store | Why |
|---|---|
sessionId |
Your handle on the session. Recoverable from the origin triple, but do not make that the normal path. |
turnId of the in-flight turn |
It is also the runId. Needed to retry safely and to attribute a terminal run event. |
| Event cursor per session | The resume point for the event log. Nothing on the server remembers it. |
grantId and its repositoryId per tenant |
Needed to create repository-backed sessions. repositoryId is required for every current grant; follow the repository source rules. Heal a replaced grant with the replacement endpoint. |
publicationId, mergeId |
Retrying a publish or merge means replaying the same id. |
| Do not store | Instead |
|---|---|
| Installation tokens | Mint on demand; they live ten minutes. |
| Event payloads as your source of truth | Keep your own domain state; re-page events when you need detail. |
session.phase |
A closed enum, but written after the fact and able to trail a run’s terminal transition. Derive your state from run.status; read phase when you render. |
| Artifact bytes you can re-fetch | Fetch by artifactId when needed; nothing reclaims them. |
The request loop
Section titled “The request loop”This is a complete backend module: token caching with refresh-on-401, session
creation, turn submission, and a retry policy that matches what the server
actually asks for.
import { SignJWT, importPKCS8 } from 'jose';
/** @param {string} name */function requiredEnv(name) { const value = process.env[name]; if (!value) throw new Error(`Set ${name}`); return value;}
const CLOUD = requiredEnv('WAMP_CLOUD_URL'); // e.g. https://api.example.comconst ACCOUNT = requiredEnv('WAMP_ACCOUNT_URL'); // may be the same originconst APP_SLUG = requiredEnv('WAMP_APP_SLUG');const KID = requiredEnv('WAMP_APP_KID');const AUD = requiredEnv('WAMP_AUD'); // the platform JWT issuer your assertion must name
const keyPromise = importPKCS8(requiredEnv('WAMP_APP_PRIVATE_KEY'), 'EdDSA');/** @type {Map<string, { token: string, refreshAt: number }>} */const tokens = new Map(); // installationId -> { token, refreshAt }
async function assertion() { const now = Math.floor(Date.now() / 1000); return new SignJWT({}) .setProtectedHeader({ alg: 'EdDSA', kid: KID }) .setIssuer(APP_SLUG) .setSubject(APP_SLUG) .setAudience(AUD) .setIssuedAt(now) .setExpirationTime(now + 300) .sign(await keyPromise);}
/** * @param {string} installationId * @param {{ force?: boolean }} [options] */async function token(installationId, { force = false } = {}) { const cached = tokens.get(installationId); if (!force && cached && cached.refreshAt > Date.now()) return cached.token;
const res = await fetch(`${ACCOUNT}/auth/app-installation-token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ assertion: await assertion(), installationId, resourceAudience: 'wamp-cloud', }), }); if (!res.ok) throw new Error(`token exchange failed: HTTP ${res.status}`);
const { token: value, expiresIn } = await res.json(); // Refresh a minute early; the token lives 600s. tokens.set(installationId, { token: value, refreshAt: Date.now() + (expiresIn - 60) * 1000, }); return value;}
const RETRYABLE = new Set([ 'cloud_workspace_starting', // 503 — sandbox is coming up 'cloud_session_finalizing', // 409 — previous run is closing out 'cloud_run_in_progress', // 409 — one run per session 'cloud_rate_limit_exceeded', // 429 'service_unavailable', // 503 — a release restart or database contention // These two carry no Retry-After, so back off rather than retrying on a fixed delay. 'cloud_workspace_unavailable', // 503 — sandbox provider could not be reached 'cloud_runtime_account_unavailable', // 503 — the runtime account is not answering]);// Note what is NOT here: cloud_conversation_busy. An interaction has no// timeout, so retrying it loops forever — answer the interaction instead.
// A 502, 503 or 504 with no JSON code came from the proxy in front of the API,// not from WAMP. The request may or may not have arrived, so only one that is// safe to repeat — a GET, or a PUT with your own id — is retried.const EDGE_FAILURE = new Set([502, 503, 504]);
/** * @param {string} installationId * @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method * @param {string} path * @param {unknown} [body] */async function call(installationId, method, path, body) { for (let attempt = 0; ; attempt += 1) { const res = await fetch(`${CLOUD}${path}`, { method, headers: { Authorization: `Bearer ${await token(installationId)}`, ...(body ? { 'Content-Type': 'application/json' } : {}), }, ...(body ? { body: JSON.stringify(body) } : {}), });
if (res.status === 204) return null; if (res.ok) return res.json();
const failure = await res.json().catch(() => ({}));
// One forced re-mint on 401. A 403 is a capability problem, not a stale token. if (res.status === 401 && attempt === 0) { await token(installationId, { force: true }); continue; }
const edgeFailure = failure.error === undefined && EDGE_FAILURE.has(res.status) && (method === 'GET' || method === 'PUT'); // At Retry-After: 2, five waits of 2, 4, 6, 8 and 10 seconds: 30 seconds in all, // enough to outlast a release. if ((RETRYABLE.has(failure.error) || edgeFailure) && attempt < 5) { const after = Number(res.headers.get('retry-after')) || 2; await new Promise((r) => setTimeout(r, after * 1000 * (attempt + 1))); continue; }
throw Object.assign( new Error(`WAMP ${res.status} (${failure.error ?? 'unknown'})`), { status: res.status, code: failure.error }, ); }}
/** * @typedef {object} Ticket * @property {string} id * @property {string} subject * @property {string} requesterId * * @typedef {object} StartAgentWorkInput * @property {string} installationId * @property {string} tenantId * @property {Ticket} ticket * @property {string} task * @property {string} model * @property {string} [grantId] * @property {string} [repositoryId] * * @typedef {object} WorkStore * @property {(tenantId: string, objectType: string, objectId: string) => Promise<string>} reserveSessionId * @property {(tenantId: string, objectType: string, objectId: string) => Promise<string>} reserveTurnId * @property {(tenantId: string, requesterId: string) => string} hashUser * @property {(sessionId: string, turnId: string) => Promise<void>} recordTurn */
/** Start work on one of your objects. Safe to call twice. `model` is an id * from `GET /v1/capabilities` (`models.items`) — never a hardcoded string. * @param {StartAgentWorkInput} input * @param {WorkStore} store */export async function startAgentWork( { installationId, tenantId, ticket, task, grantId, repositoryId, model }, store,) { if (grantId && !repositoryId) { throw new Error('repositoryId is required with grantId'); } const sessionId = await store.reserveSessionId(tenantId, 'support_ticket', ticket.id); const turnId = await store.reserveTurnId(tenantId, 'support_ticket', ticket.id);
const { session, initialTurn } = await call(installationId, 'PUT', `/v1/sessions/${sessionId}`, { initialTurn: { id: turnId, message: task }, title: ticket.subject.slice(0, 160), model, origin: { tenantKey: tenantId, objectType: 'support_ticket', objectId: ticket.id, endUserId: store.hashUser(tenantId, ticket.requesterId), label: ticket.subject.slice(0, 256), url: `https://app.example.com/tickets/${ticket.id}`, }, ...(grantId ? { source: { kind: 'github', grantId, ...(repositoryId ? { repositoryId } : {}), }, } : {}), });
await store.recordTurn(sessionId, turnId); // turnId === runId return { sessionId, runId: initialTurn.run.id, sessionUrl: session.sessionUrl };}Then follow the run from your worker — see Follow a run live for the polling loop and the resume-after-restart rules.
Fitting it to your product
Section titled “Fitting it to your product”A web app. The button handler calls startAgentWork and returns your own
job id. A worker follows events and writes progress rows your frontend polls or
subscribes to. Hand the user session.sessionUrl if you want them to watch the
run in WAMP Cloud; it is a first-party deep link meant for people, not an API.
A chat bot. One session per thread, keyed on the thread id in origin.
Each user message becomes a turn on the same session, so the agent keeps
context. Submitting while a run is active is 409 cloud_run_in_progress — queue
the message on your side, or tell the user the agent is still working. A message
sent once the run has ended, while the session is still finalizing, is
accepted and starts as soon as the run’s checkpoint is sealed. Telegram
Cloud is a first-party reference /v1 consumer of this shape; the Cloud
Workspace web app is not — people there use cookies, not this credential.
A scheduler. One session per scheduled task instance, not one long-lived
session, so a failure has a bounded blast radius. Archive with DELETE /v1/sessions/{sessionId} when you are done. It returns 204 and is not
destructive: reads cross the archive boundary, commands do not. The
session, its event log, turns, artifacts, interactions and publications stay
readable, while every write is refused — so the same session answers 200 on a
GET and 404 cloud_session_not_found on a merge. An archived session also
reports continuation.canContinue: false with reason: "session_archived" and
can never admit another turn. There is no un-archive.
Archiving also changes what GET /v1/sessions returns you. The archived
filter defaults to exclude, so an archived session silently disappears from
the list. Pass archived=include to page both, or archived=only to page just
the archived ones.
A CRM or ticketing integration. origin.objectType and objectId are
exactly the ticket coordinates, and the unique constraint above keeps one agent
per ticket. Store the resulting pull request URL from the publication result
back on the ticket.
Failure modes worth handling up front
Section titled “Failure modes worth handling up front”| Situation | Response | What to do |
|---|---|---|
| Sandbox still starting | 503 cloud_workspace_starting + Retry-After: 2 |
Retry |
| Previous run still closing | 409 cloud_session_finalizing + Retry-After: 2 |
Retry |
| A run is already active | 409 cloud_run_in_progress + Retry-After: 2 |
Queue or wait |
| WAMP is restarting the API for a release, or its database is briefly contended | 503 service_unavailable + Retry-After: 2 |
Retry the identical request; it lasts seconds |
| The proxy in front of the API answered | 502, 503 or 504 with no JSON error |
Retry a GET or an own-id PUT with backoff for up to about 30 seconds |
| Agent is waiting on a question | 409 cloud_conversation_busy |
Answer the open interaction. Never retry — an open interaction has no timeout |
| Ambiguous turn start failure | 502 cloud_turn_start_failed |
Re-PUT the same turn id — it is idempotent |
| Capability missing | 403 insufficient_scope with requiredScope |
Ask the administrator to re-consent. Never retry |
The full table is in Errors. The rule of thumb: retry the
codes that carry Retry-After, back off on the two 503s that
do not (cloud_workspace_unavailable, cloud_runtime_account_unavailable),
re-PUT the same id on an ambiguous 502, back off on a 502, 503 or 504
with no code when the request is safe to repeat, and treat every other 4xx as
terminal. Budget the retries in time rather than attempts: a release can keep
answering 503 for several seconds.
Related
Section titled “Related”- API conventions — ids, paging, idempotency, headers.
- Limits — caps that produce silent failures if ignored.
- SDK — what
@wamp/app-sdkcovers and whether you can install it. - Connect a repository — obtaining a
grantIdand supplying itsrepositoryId.