Skip to content

Users

Run endpoints for one user and push live events to that user

What is the user scope?

A thread belongs to one conversation. The user scope belongs to one user and spans every thread that user owns. It has five parts:

  • User endpoints: HTTP routes that run for one user
  • User values: durable JSON values for one user
  • User event stream: a WebSocket that delivers live events to the user's connected clients
  • User room: an optional WebSocket hub that receives frames from the user's clients
  • Credential grants: credentials that the runtime adds to requests for the user

Use the user scope for data that more than one thread reads: preferences, quotas, counters, and registries.

User endpoints

Quick start

Create a file in agents/users/ and export a defineUserEndpoint call as the default export:

// agents/users/preferences.ts
import { defineUserEndpoint } from '@standardagents/spec';

export default defineUserEndpoint(async (_req, user) => {
  const theme = (await user.getValue<string>('preferences.theme')) ?? 'light';
  return Response.json({ userId: user.userId, theme });
});

The endpoint is available at GET /api/users/me/preferences. Standard Agents Builder discovers it from the agents/users/ directory.

File-based routing

Routing follows the rules of thread endpoints. The path sets the route, a suffix sets the method, and [name] and [*] capture path segments.

File HTTP method Route
preferences.ts GET /api/users/me/preferences
preferences.put.ts PUT /api/users/me/preferences
quota/spend.post.ts POST /api/users/me/quota/spend
items/[id].ts GET /api/users/me/items/:id

me is the authenticated caller. Every route under /api/users/me/ requires authentication. A super admin session has no user account, so a me route returns HTTP 400 for it.

The paths env, kv, hub, and stream directly under /api/users/me/ belong to built-in routes. Do not name a user endpoint file after one of them.

A request that matches no user endpoint returns HTTP 404.

Handler function

declare function defineUserEndpoint(
  handler: (
    req: Request,
    user: UserState,
    params: Record<string, string>
  ) => Response | Promise<Response>
): MarkedUserEndpoint;
  • req: the Web API Request
  • user: the UserState of the addressed user
  • params: the values captured from [name] and [*] path segments

A user endpoint receives no ThreadState. An exception that the handler does not catch returns HTTP 500.

One run at a time for each user

For one user, Standard Agents Builder runs one user endpoint at a time. Each run sees every write that earlier runs completed. A user endpoint can therefore read a value, check a limit, and write the new value without a race:

// agents/users/quota/spend.post.ts
import { defineUserEndpoint } from '@standardagents/spec';
import { z } from 'zod';

const SpendInput = z.object({
  amount: z.number().int().positive(),
});

const DAILY_LIMIT = 100;

export default defineUserEndpoint(async (req, user) => {
  const input = SpendInput.safeParse(await req.json().catch(() => null));
  if (!input.success) {
    return Response.json({ error: 'Send a positive integer "amount".' }, { status: 400 });
  }

  const key = `quota.${new Date().toISOString().slice(0, 10)}`;
  const used = (await user.getValue<number>(key)) ?? 0;
  const next = used + input.data.amount;
  if (next > DAILY_LIMIT) {
    return Response.json({ ok: false, used, limit: DAILY_LIMIT }, { status: 429 });
  }

  await user.setValue(key, next);
  user.emit('quota_changed', { used: next, limit: DAILY_LIMIT });
  return Response.json({ ok: true, used: next, limit: DAILY_LIMIT });
});

This guarantee covers user endpoints and state.user().call(). It does not cover direct value writes from thread code, such as (await state.user())?.setValue(...).

UserState

Member Description
userId The user's ID
identity() The user's profile: id, username, email, display_name, avatar_url, role, roles, and permissions
getValue(key) Reads one durable JSON value. Returns null when the key has no value
setValue(key, value) Writes one value. A null value deletes the key
listValues({ prefix, limit, offset }) Lists the user's values
env(name) Reads a variable from the user-account environment and then from the instance environment. Rejects when neither defines the name
instanceEnv(name) Reads a variable from the instance environment only. Returns undefined when the name is not set
emit(event, data) Sends a live event to the user's connected clients
listThreads({ agentId, limit, offset }) Lists the user's threads
thread(threadId) Returns the ThreadState of one of the user's threads. Rejects for a thread that another user owns
fetch(grant, input, init) Sends a request with the credential of a credential grant
clients() Lists the clients connected to the user room
send(clientId, frame) Sends one frame to one client of the user room. Returns false when that client is not connected
roomAttachments Optional methods to inspect, admit, address, or close room attachments

User values are plain JSON. Do not store credentials in them. Store a credential for an external API as a credential grant.

Calling a user endpoint from a thread

Tools, hooks, handlers, and thread endpoints reach the thread's user through state.user(). call() runs a user endpoint by its path under agents/users/, with the same one-at-a-time guarantee.

// agents/tools/spend_quota.ts
import { defineTool } from '@standardagents/spec';
import { z } from 'zod';

export default defineTool({
  description: 'Spend units of the daily quota before metered work.',
  args: z.object({
    amount: z.number().int().positive().describe('Units to spend'),
  }),
  execute: async (state, args) => {
    const user = await state.user();
    if (!user) {
      return { status: 'error', error: 'This thread has no user.' };
    }

    const response = await user.call('quota/spend', { amount: args.amount });
    if (!response.ok) {
      return { status: 'error', error: 'The daily quota is used up.' };
    }
    return { status: 'success', result: await response.text() };
  },
});
  • call() sends a POST request with the payload as a JSON body. It reaches endpoints that handle POST.
  • call() rejects a path that contains a . or .. segment, in plain, percent-encoded, or backslash form.
  • state.user() returns null for a thread without a user.
  • A ThreadState that came from user.thread() cannot use call(). The call throws, because the user endpoint that raised the thread is still running.

The value returned by state.user() also has getValue(), setValue(), emit(), and fetch() for the same user.

Caller channel

Standard Agents Builder sets the X-Standard-Agents-Caller request header on every user endpoint request. The public routes overwrite any value that the client sent.

Value Caller
user The user's own HTTP request to /api/users/me/...
admin An administrator's request to /api/users/:id/...
thread state.user().call() from thread code

Check for thread in an endpoint that only your agents may change:

// agents/users/credits/grant.post.ts
import { defineUserEndpoint } from '@standardagents/spec';

export default defineUserEndpoint(async (req, user) => {
  if (req.headers.get('X-Standard-Agents-Caller') !== 'thread') {
    return Response.json({ error: 'Forbidden' }, { status: 403 });
  }
  const credits = (await user.getValue<number>('credits')) ?? 0;
  await user.setValue('credits', credits + 1);
  return Response.json({ credits: credits + 1 });
});

Administrative access

An administrator can call the same endpoints for another user at /api/users/:id/.... The endpoint runs in the addressed user's scope. A caller that is not an administrator receives HTTP 403.

To guard an endpoint with application roles, see application role checks.

User event stream

The user event stream delivers events from user.emit() and from the handle returned by await state.user() to the user's connected clients.

Connecting

GET /api/users/me/stream     (WebSocket upgrade)
GET /api/users/:id/stream    (WebSocket upgrade, administrators)

A browser cannot set an Authorization header on a WebSocket. Request a single-use ticket for the exact path and pass it as ?ticket=, as the thread stream does:

async function connectUserStream(origin: string, token: string): Promise<WebSocket> {
  const pathname = '/api/users/me/stream';
  const response = await fetch(`${origin}/api/auth/websocket-ticket`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ pathname }),
  });
  const { ticket } = (await response.json()) as { ticket: string };

  const ws = new WebSocket(`${origin.replace(/^http/, 'ws')}${pathname}?ticket=${ticket}`);
  ws.onmessage = (message) => {
    if (message.data === 'stream_pong') return;
    const frame = JSON.parse(String(message.data)) as { event: string; data?: unknown };
    console.log(frame.event, frame.data);
  };
  return ws;
}

A request without a WebSocket upgrade returns HTTP 426. A user can hold several connections at the same time, one for each device or tab.

Frames

Each frame is a JSON object:

interface UserStreamFrame {
  event: string;
  data?: unknown;
}
{ "event": "quota_changed", "data": { "used": 12, "limit": 100 } }
  • Standard Agents Builder sends { "event": "stream_ready", "data": { "client_id": "..." } } as the first frame of each connection. Pass ?client_id= to set that ID, and ?client_kind= to label the client.
  • data is absent when the event was emitted without data.
  • Frames arrive in the order the user's code emitted them.
  • Choose event names that include your package or feature name, such as quota_changed.

Delivery

The stream is live only. It stores no history and has no acknowledgements. A client that was not connected when an event was emitted does not receive that event.

After each connect and reconnect, buffer incoming events while you read the current state from your user endpoints. Reconcile buffered events with the snapshot before you apply later events.

Keepalive

Send the string stream_ping at intervals. The runtime answers stream_pong.

User rooms

What is a room?

A user room is a WebSocket hub for one user's connected clients. A project has at most one room. The room adds three things to the user event stream:

  • Client frames: a client sends a frame on the socket, and your code handles it in the user scope
  • Targeted delivery: your code sends a frame to one client by its ID
  • Presence callbacks: your code runs when a client attaches or detaches

Room frames and presence callbacks run one at a time for each user, in the same queue as user endpoints.

When a project needs a room

Define a room when one of these is true:

  • Clients must send messages to your user-scope code over an open socket.
  • Your code must address one device or tab of the user.
  • Your code must know which clients are connected.
  • Your code must verify a client before that client receives frames.

The user event stream is enough when your code only pushes events to all of the user's clients and the clients send their changes through user endpoints.

Defining a room

Create agents/users/<package>/room.ts and export a defineUserRoom call as the default export:

// agents/users/my_package/room.ts
import { defineUserRoom } from '@standardagents/spec';

export default defineUserRoom({
  async onFrame(user, connection, frame) {
    if (frame.op === 'ping') {
      await user.send(connection.clientId, { type: 'room', id: frame.id, ok: true });
    }
  },
  async onPresence(user, event) {
    user.emit('my_package.presence', {
      client: event.connection.clientId,
      online: event.kind === 'attached',
    });
  },
});
  • onFrame runs for each room frame that a client sends.
  • onPresence is optional. It runs when a client attaches and when a client detaches. In the default admission mode, a client that reconnects within 30 seconds causes no presence change.
  • A room file is not an HTTP route. A request to its path returns HTTP 404.
  • When two files define a room, Standard Agents Builder refuses room connections with HTTP 503. User endpoints and the user event stream continue to work.

Hub route

GET /api/users/me/hub?client_id=...     (WebSocket upgrade)
GET /api/users/:id/hub?client_id=...    (WebSocket upgrade, administrators)

Authenticate with a single-use ticket for the exact path, as the user event stream does. A request without a WebSocket upgrade returns HTTP 426.

  • Standard Agents Builder sends { "type": "hub_ready" } as the first frame.
  • client_id identifies the client. A connection without client_id is not a room connection. The optional client_kind labels the client. Each value is trimmed and cut to 256 characters.
  • A room frame is a JSON object with "type": "room" and a string id of 1 to 256 characters, such as { "type": "room", "id": "1", "op": "ping" }. The other fields belong to your protocol. The runtime sends no acknowledgement, so reply with user.send() and repeat the id.
  • A room frame in a project without a room receives { "type": "room", "id": "...", "error": "no_room" }.
  • user.emit() events also reach room connections, as { "event": "...", "data": ... } frames.
  • In the default admission mode, a new connection with the same client_id replaces the earlier one. The earlier socket receives { "type": "superseded" } and closes with code 4001. A client that receives this frame stops its reconnect attempts.

Admission rules

A room has its own admission mode. The default is automatic: each connection with a client_id joins the room at once.

With admission: 'explicit', a new connection starts as pending. A pending connection receives no user.send() frames and no user.emit() events, and user.clients() does not list it. onFrame still receives its frames, so your code can verify a proof from the client. Reply to a pending connection with user.roomAttachments.send(), admit it with user.roomAttachments.admit(), or reject it with user.roomAttachments.close(). Each call takes connection.attachmentId. An admitted connection replaces the earlier admitted connection with the same client_id. In this mode a connection without client_id returns HTTP 400.

A room can reserve user-value keys with valuePrefix. Client-facing KV routes reject writes under that prefix. Changes to those keys do not run user_value_changed.

Room connections do not run the user_client_attached and user_client_detached hooks. Those hooks belong to the user event stream.

User lifecycle hooks

Three hook types run in the user scope. Each receives a UserState in place of a ThreadState.

Hook Arguments Runs when
user_client_attached (user, client) A client connects to the user event stream
user_client_detached (user, client) A client disconnects from the user event stream
user_value_changed (user, key) A user value changes

client is { id: string; kind?: string }.

// agents/hooks/announce_client.ts
import { defineHook } from '@standardagents/spec';

export default defineHook({
  hook: 'user_client_attached',
  id: 'announce_client',
  execute: async (user, client) => {
    user.emit('client_attached', { id: client.id, kind: client.kind ?? null });
  },
});

A user hook needs no reference from a prompt or an agent. It runs for every user of the project while its file exists. A user hook observes the operation and cannot block it. Standard Agents Builder discards an error that a user hook throws.

Next steps