Your own backend
You already have a product, a server, and users. You do not want a desktop shell or a marketplace listing; you want metered model access with your own users attributed individually. That is what an app principal is: a server-side identity keyed by an Ed25519 keypair you generate and hold, whose signature your backend uses to mint short-lived tokens for individual end users. Those users’ clients then talk to the AI proxy directly — your backend never relays model traffic.
The shape is deliberately the same as a GitHub App: you hold a private key, a customer organization installs your app and grants it capabilities, and every call is attributed to the installation.
The pieces
Section titled “The pieces”| Thing | Who holds it | What it is |
|---|---|---|
| App | you | a slug registered under an organization, with a display name |
| Signing key | you only | an Ed25519 keypair. WAMP stores the public half and a kid derived from it; the private half never leaves your infrastructure |
| Installation | a customer organization | the grant of capabilities to your app in that organization. Identified by a UUID your backend keeps |
| End-user token | minted per request-burst by your backend | a short-lived JWT signed by your key, naming one of your users |
1 · Register the app and its key
Section titled “1 · Register the app and its key”Both surfaces are clients of the same management API: Account Center → Apps in the browser, and the Developer Console inside a WAMP desktop build. Either way you pick an organization, choose a slug and a name, then generate a keypair and register the public half.
POST /api/apps { orgId, slug, name }POST /api/apps/:slug/keys { publicKeyPem } → { kid, ... }DELETE /api/apps/:slug/keys/:kidorgId is a required UUID on create, and a required query parameter when
listing your apps — an app is always scoped to one organization. publicKeyPem
must be an SPKI PEM (-----BEGIN PUBLIC KEY-----). The returned kid is the
RFC 7638 JWK thumbprint of the key, which means you can compute it yourself and
do not have to store what the server told you.
Generate the keypair anywhere you like — both first-party surfaces do it
locally, in the browser and in the desktop app respectively, and neither ever
transmits the private half. crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify']) is the whole of it.
2 · Get an installation
Section titled “2 · Get an installation”Your app is registered, but registration alone authorizes nothing. An
organization has to install it, and the installation has to carry the AI
capability — a valid signature on a token is not sufficient, because the
proxy independently checks that the installation holds wamp.ai.invoke on the
wamp-ai resource. An organization admin performs that grant.
For your own organization, install the app from the same Apps surface where you registered it. For a customer’s organization, the flow avoids asking them for a UUID: your backend opens an installation intent naming the capabilities you want, the customer’s admin reviews and authorizes it against their organization, and you poll for the result.
POST /api/apps/installation-intents { assertion, resourceAudience, capabilityIds }POST /api/apps/installation-intents/:id/status { assertion }GET /api/apps/installation-intents/:id (the admin's review screen)POST /api/apps/installation-intents/:id/authorize { orgId } (the admin authorizes)The assertion is a short-lived JWT signed by your app key — your credential on
the intent routes, which take no human session. What you keep from the finished
intent is the installation id; every token you mint afterwards names it.
3 · Mint an end-user token
Section titled “3 · Mint an end-user token”This is the only cryptography you have to perform, and it is one JWT. Sign it with your app’s private key on every request-burst for a signed-in user of your product:
| Claim | Value |
|---|---|
header alg |
EdDSA |
header kid |
your key’s thumbprint |
iss |
your app slug — the app is the issuer, not the platform |
sub |
your own opaque id for the user. Matches ^(anon:)?[A-Za-z0-9._:-]{1,128}$, so hash it. Never PII, never a WAMP user id |
aud |
wamp-proxy |
installation_id |
the installation UUID from step 2 |
iat / exp |
issued-at and expiry |
Signature verification is strict: EdDSA only, the kid must resolve to a
non-revoked key of an app whose slug equals iss, the audience must be exactly
wamp-proxy, a suspended app fails closed, and clock skew is tolerated to 30
seconds. Anything issued by the platform itself is rejected on this path — the
issuer being your slug is what keeps end-user tokens out of the human-session
code path.
Nothing enforces a maximum lifetime, so “short-lived” is your decision. The reference client defaults to one hour. Treat the token as a bearer credential that reaches a browser and pick the shortest TTL your refresh story can carry.
import { createPrivateKey } from 'node:crypto';import { SignJWT } from 'jose';
const key = createPrivateKey(process.env.WAMP_APP_KEY); // PKCS#8 PEMconst now = Math.floor(Date.now() / 1000);
export async function tokenFor(userId) { return new SignJWT({ installation_id: process.env.WAMP_INSTALLATION_ID }) .setProtectedHeader({ alg: 'EdDSA', kid: process.env.WAMP_APP_KID }) .setIssuer('your-app-slug') .setSubject(hashOf(userId)) .setAudience('wamp-proxy') .setIssuedAt(now) .setExpirationTime(now + 900) .sign(key);}4 · Call the proxy from the client
Section titled “4 · Call the proxy from the client”The AI proxy is a WebSocket at /ws. The token goes in the Authorization
header where the client can set headers, and in a ?token= query parameter
where it cannot — a browser cannot set headers on a WebSocket handshake, and the
query form is accepted for signed tokens for exactly that reason.
const ws = new WebSocket(`wss://api.vampikez.fun/ws?token=${token}`);
ws.onopen = () => ws.send(JSON.stringify({ type: 'ai_request', id: crypto.randomUUID(), payload: { model: 'claude-sonnet-5', messages: [{ role: 'user', content: 'Summarize this thread in three bullets.' }], max_tokens: 1024, },}));
ws.onmessage = (event) => { const msg = JSON.parse(event.data); if (msg.type === 'text_delta') append(msg.delta); if (msg.type === 'stream_end') done(msg.usage);};The server sends connected on accept, then per request: stream_start,
text_delta and thinking_delta chunks, tool_use when the model calls a
tool, and stream_end carrying usage with input, output, and cache token
counts. Failures arrive as error with a code and a message; throttling
arrives as rate_limit with remaining and resetAt. Send
{ type: 'abort', id } to cancel an in-flight request.
Which model ids you may pass depends on which providers the server has keys for.
GET /api/models is public and returns the available catalog, the aliases for
older ids, and the defaults — call it at startup and cache it rather than
hardcoding a list.
What WAMP attributes to whom
Section titled “What WAMP attributes to whom”Every request writes one usage record. For an app principal it carries your
appId, the appInstallationId, the organization, the end-user subject, the
model, token counts, cost, and latency — and no WAMP user, because there is not
one involved. Failed requests are recorded too.
The end-user subject comes from inside your signature, so it cannot be
overridden: a payload end_user_id that disagrees with a signed end-user token
is rejected as an attribution conflict rather than quietly preferred. (A backend
calling with an app-level credential rather than an end-user token does set
end_user_id in the payload, since in that case the app is the only thing
asserting who its user was.)
You read it back per app, filtered by end user and time range:
GET /api/apps/:slug/usage?endUserId=&from=&to=&limit=GET /api/apps/:slug/usage/summary@wamp/app-sdk, and what to do until it ships
Section titled “@wamp/app-sdk, and what to do until it ships”The SDK wraps all of the above. Its real export surface — verified against the source, since one name in circulation is out of date:
| Export | What it is |
|---|---|
generateAppKeypair() |
returns { privateKeyPem, publicKeyPem, kid } |
WampApp |
the app principal. issueUserToken({ endUserId, installationId, ttlSeconds? }), installationToken(installationId, resourceAudience?, opts?), createInstallationIntent(resourceAudience, capabilityIds), getInstallationIntent(id), buildAssertion() |
verifyWebhook({ payload, signature, secret }) |
checks a t=<unix>,v1=<hex> header — HMAC-SHA256 over `${t}.${body}` — with a timestamp tolerance |
WampCloud, WampCloudError |
the Cloud session client: sessions, turns, runs, artifacts, publications |
END_USER_TOKEN_AUDIENCE, WAMP_CLOUD_RESOURCE_AUDIENCE |
the two audience strings, wamp-proxy and wamp-cloud |
There is no appToken function; the backend-credential call is
WampApp.installationToken(), whose second argument is the resource audience you
want the token for.
// The shape once it is installable:import { WampApp } from '@wamp/app-sdk';
const app = new WampApp({ slug: 'your-app-slug', privateKeyPem: process.env.WAMP_APP_KEY,});
const token = await app.issueUserToken({ endUserId: hashOf(userId), installationId: process.env.WAMP_INSTALLATION_ID,});This and accounts.mode: 'own' are one mechanism
Section titled “This and accounts.mode: 'own' are one mechanism”A branded desktop product can declare accounts: { mode: 'own', appSlug: … }
and get a hosted user directory: WAMP runs sign-up and sign-in for those users
and mints their AI credentials. That is not a second system. The tokens it mints
have the same claims described in step 3 — same iss, same aud, same
installation_id, same algorithm — and the proxy verifies them through the
identical code path, with no branch for hosted versus self-signed. The only
difference is which side holds the signing key.
Two consequences worth planning around:
- Switching later costs nothing. A product that starts on hosted accounts and later stands up its own backend registers its own key and takes over. There is nothing to migrate, because the credential contract does not change.
- Hosted accounts add a public auth surface you do not have. Sign-up,
sign-in, anonymous identity, refresh, and “who am I” are served under
/a/:slug/…, along with a per-app document store on the same end-user token, so a user’s data follows them between machines. Minting your own tokens means those are your problem — you already have them, which is why you are on this page.
The hosted side is Hosted end-user accounts; what an app principal is in the wider identity model is Apps and credentials.
Beyond model calls
Section titled “Beyond model calls”An installation token issued for the wamp-cloud audience lets your backend
drive durable agent sessions on WAMP’s own infrastructure — a long-running task
started from your CRM, bot, or scheduler, rather than a streaming completion in a
user’s client. That product has its own documentation at
docs.cloud.vampikez.fun.