Skip to content

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.

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

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/:kid

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

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.

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 PEM
const 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);
}

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.

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.

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.