Skip to content

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.

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 token

Three 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.

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.

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.

Terminal window
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:

  1. Cache per installation. Key the cached token by installationId. A multi-tenant backend holds one token per active customer, not one globally.

  2. 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.

  3. Refresh on 401, once. If a call fails with 401 invalid_or_expired_credential, discard the cached token, mint a new one, and retry the request exactly once. Do not loop: a 403 means the installation lacks a capability, and re-minting will never fix it.

  4. 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.

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.

{
"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.

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-48213

Treat 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.

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.

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.com
const ACCOUNT = requiredEnv('WAMP_ACCOUNT_URL'); // may be the same origin
const 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.

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.

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.

  • API conventions — ids, paging, idempotency, headers.
  • Limits — caps that produce silent failures if ignored.
  • SDK — what @wamp/app-sdk covers and whether you can install it.
  • Connect a repository — obtaining a grantId and supplying its repositoryId.