# Drive Cloud from a backend The integration shape for a CRM, bot, scheduler, or web app — where the signing key lives, when to mint tokens, how to map your users onto sessions, and what your database has to store. Source: https://docs.cloud.vampikez.fun/guides/from-your-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 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. ## 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. ```js check 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](/reference/sdk/) for whether the package is installable for you. The same keypair is ten lines of `jose`, shown on [Authentication](/start/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 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. ```bash curl -sS -X POST "$WAMP_ACCOUNT/auth/app-installation-token" \ -H 'Content-Type: application/json' \ -d '{ "assertion": "", "installationId": "8a0f1c6b-92d4-4e73-b5a1-0d3e7f2c9b48", "resourceAudience": "wamp-cloud" }' ``` ```json { "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. The token exchange is served by WAMP Account and `/v1` by WAMP Cloud. In a single-origin deployment both are the same host; in a split deployment they are not. Keep them as two settings from day one — the OpenAPI document declares a single server origin, which is accurate only for the single-origin case. Every Cloud request then carries: ``` Authorization: Bearer ``` 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 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 ```json { "initialTurn": { "id": "", "message": "Fix the failing invoice test and open a PR." }, "model": "", "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](#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 `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 ``` The three `origin*` parameters match exactly — no prefixes, ranges or wildcards — and `originTenantKey` is required whenever either of the other two is sent, because it is what keeps the lookup indexed. There is still no filter by status or date, and the query is validated strictly, so an invented parameter is a `400`, not an ignored hint. 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. ```sql 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 | 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](/guides/connect-a-repository/#attach-it-to-a-session). 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 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. ```js check /** @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} */ 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} reserveSessionId * @property {(tenantId: string, objectType: string, objectId: string) => Promise} reserveTurnId * @property {(tenantId: string, requesterId: string) => string} hashUser * @property {(sessionId: string, turnId: string) => Promise} 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](/guides/follow-a-run/) for the polling loop and the resume-after-restart rules. ## 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 | 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](/reference/errors/). The rule of thumb: retry the codes that carry `Retry-After`, back off on the two `503`s 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 - [API conventions](/reference/api/) — ids, paging, idempotency, headers. - [Limits](/reference/limits/) — caps that produce silent failures if ignored. - [SDK](/reference/sdk/) — what `@wamp/app-sdk` covers and whether you can install it. - [Connect a repository](/guides/connect-a-repository/) — obtaining a `grantId` and supplying its `repositoryId`.