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.

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
Webhook signing secret (whsec_…) Secret manager, one per endpoint Delete the endpoint and register a new one

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 WAMP issuer, and a lifetime of at most 600 seconds. The field name in the request body is assertion, and it is one of exactly three accepted keys — the request is validated strictly.

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.

{
"task": "Fix the failing invoice test and open a PR.",
"model": "claude-sonnet-5",
"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, for humans reading it in WAMP Cloud
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.

The point of origin is the reverse direction: a person looking at a session in WAMP Cloud can see which ticket it came from and click through to it. 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.

Your own index — the lookup that actually matters

Section titled “Your own index — the lookup that actually matters”

So the session id is yours to keep. Mint it, store it against your object, and never rediscover it by listing.

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
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 only handle on the session. Cannot be searched for.
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 per tenant Needed to create repository-backed sessions; heal it with the replacement endpoint.
publicationId, mergeId Retrying a publish or merge means replaying the same id.
Webhook event ids or last_sequence Deduplication for at-least-once delivery.
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 It is advisory display text over an open set of values. Derive your state from run.status.
Artifact bytes you can re-fetch Fetch by artifactId when needed, unless you need them after retention.

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';
import { randomUUID } from 'node:crypto';
const CLOUD = process.env.WAMP_CLOUD_URL; // e.g. https://api.example.com
const ACCOUNT = process.env.WAMP_ACCOUNT_URL; // may be the same origin
const APP_SLUG = process.env.WAMP_APP_SLUG;
const KID = process.env.WAMP_APP_KID;
const ISSUER = process.env.WAMP_ISSUER; // the `aud` your assertion must carry
const keyPromise = importPKCS8(process.env.WAMP_APP_PRIVATE_KEY, 'EdDSA');
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(ISSUER)
.setIssuedAt(now)
.setExpirationTime(now + 300)
.sign(await keyPromise);
}
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
]);
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;
}
if (RETRYABLE.has(failure.error) && attempt < 5) {
const after = Number(res.headers.get('retry-after')) || 2;
await new Promise((r) => setTimeout(r, after * 1000 * (attempt + 1)));
continue;
}
const err = new Error(`WAMP ${res.status} (${failure.error ?? 'unknown'})`);
err.status = res.status;
err.code = failure.error;
throw err;
}
}
/** Start work on one of your objects. Safe to call twice. */
export async function startAgentWork({ installationId, tenantId, ticket, task, grantId }) {
const sessionId = await reserveSessionId(tenantId, 'support_ticket', ticket.id);
const { session } = await call(installationId, 'PUT', `/v1/sessions/${sessionId}`, {
task,
title: ticket.subject.slice(0, 160),
model: 'claude-sonnet-5',
origin: {
tenantKey: tenantId,
objectType: 'support_ticket',
objectId: ticket.id,
endUserId: hashUser(tenantId, ticket.requesterId),
label: ticket.subject.slice(0, 256),
url: `https://app.example.com/tickets/${ticket.id}`,
},
...(grantId ? { source: { kind: 'github', grantId } } : {}),
});
const turnId = randomUUID();
const admission = await call(
installationId,
'PUT',
`/v1/sessions/${sessionId}/turns/${turnId}`,
{ message: task },
);
await recordTurn(sessionId, turnId); // turnId === runId
return { sessionId, runId: turnId, 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, or Receive webhooks to be woken up instead of polling idle sessions.

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 scheduler. One session per scheduled task instance, not one long-lived session, so a failure has a bounded blast radius and the 100-active-session cap per organization is not a queue you have to manage. Archive with DELETE /v1/sessions/{sessionId} when you are done; it is a soft delete and returns 204.

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
Agent is waiting on a question 409 cloud_turn_unresolved Answer the open interaction first
Organization at the session cap 429 cloud_session_limit_reached Archive finished sessions
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 three codes that carry Retry-After plus 429, re-PUT the same id on an ambiguous 502, and treat every other 4xx as terminal.

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