API
Create custom API endpoints that operate on specific threads
What Are Thread API Endpoints?
Thread API endpoints are custom routes that operate on specific conversation threads. They allow you to:
- Access thread data: Query messages, logs, and metadata
- Perform operations: Export conversations, generate summaries, update state
- Extend the API: Add domain-specific functionality beyond built-in routes
Quick Start
Create your first endpoint in agents/api/status.ts:
import { defineThreadEndpoint } from '@standardagents/spec';
export default defineThreadEndpoint(async (req, state) => {
const { messages, total } = await state.getMessages();
return Response.json({
threadId: state.threadId,
agentId: state.agentId,
messageCount: total,
});
});Guarding User Endpoints with Application Roles
Hosted projects assign application roles in Platform → project Settings →
Users. Platform pushes assignments to the Standard Agents Builder instance; applications
read the local identity without making a Platform request. These roles are
separate from the built-in admin, user, and api access levels and the
permissions field. Existing sessions and personal API keys resolve the user's
current replicated assignment.
Use a user endpoint when access belongs to the authenticated user:
// agents/users/private-report.get.ts
import { defineUserEndpoint } from '@standardagents/spec';
export default defineUserEndpoint(async (_req, user) => {
const { roles = [] } = await user.identity();
if (!roles.includes('reports:read')) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
return Response.json({ report: await user.getValue('private_report') });
});Role strings are opaque and case-sensitive. Your application can choose a
convention such as allowing either releases:canary or releases:*, but must
implement that check explicitly. There is no automatic wildcard matching or
administrative bypass. Missing roles grant no application access.
Tools and thread endpoints can inspect (await state.user())?.roles; that
identity belongs to the thread owner, which may differ from an administrator
addressing the thread. Choose the identity that owns the protected operation.
Check authorization in each request or execution.
state.user() reads the owner's current local identity on each call.
A user endpoint's user.identity() lookup is memoized for that execution.
A role change does not cancel work in flight.
Enforce checks on the server.
Treat client-supplied role lists as untrusted input.
File-Based Routing
API files map directly to routes based on their filename:
| File | HTTP Method | Route |
|---|---|---|
status.ts |
GET | /api/threads/:threadId/status |
status.get.ts |
GET | /api/threads/:threadId/status |
export.post.ts |
POST | /api/threads/:threadId/export |
metadata.put.ts |
PUT | /api/threads/:threadId/metadata |
archive.delete.ts |
DELETE | /api/threads/:threadId/archive |
index.ts |
GET | /api/threads/:threadId |
items/[id].ts |
GET | /api/threads/:threadId/items/:id |
files/[*].ts |
GET | /api/threads/:threadId/files/* |
Method Suffixes
Append the HTTP method to the filename to specify which method the endpoint handles:
.get.ts— GET requests (default if no suffix).post.ts— POST requests.put.ts— PUT requests.delete.ts— DELETE requests.patch.ts— PATCH requests
Files without a method suffix default to GET.
Method suffixes are case-insensitive. Dynamic route segments use [name] and are passed to the handler as params.name. Catch-all segments use [*] and are passed as params['*']; the catch-all value may contain / when it matches nested paths. Static routes are matched before dynamic routes, and dynamic routes are matched before catch-all routes.
Nested Routes
Create subdirectories for nested route paths:
agents/api/
├── status.ts # GET /api/threads/:threadId/status
├── export.post.ts # POST /api/threads/:threadId/export
└── messages/
└── count.ts # GET /api/threads/:threadId/messages/countHandler Function
defineThreadEndpoint marks the original handler.
The marked value keeps the (req, state, params, auth) signature.
A runtime adapter performs these steps:
- Extracts the thread ID from the URL
- Looks up the thread.
- Creates a ThreadState.
- Handles errors (400 for missing ID, 404 for not found, 500 for exceptions).
Signature
declare function defineThreadEndpoint(
handler: (
req: Request,
state: ThreadState,
params: Record<string, string>,
auth: ThreadEndpointAuth
) => Response | Promise<Response>,
options?: EndpointOptions
): MarkedThreadEndpoint;Parameters
req: Standard Web API Request object
state: ThreadState exposes thread data and capabilities.
state.execution is null for endpoint calls.
params: Route parameters captured from [name] and [*] path segments.
auth: auth.caller contains the resolved caller or null.
auth.threadAccess states whether the runtime grants access to this thread.
Endpoints default to { access: 'authenticated' }. The runtime verifies the caller and thread access before it calls your handler. Use auth.caller for the request's identity. state.user() identifies the thread owner.
With { access: 'public' }, the runtime can call your handler for an anonymous caller. Inspect auth.threadAccess before you return protected thread data.
ThreadState in Endpoints
The state parameter provides access to the thread's data:
Identity
Access thread identity directly:
state.threadId // Thread UUID
state.agentId // Agent name
state.userId // Optional user identifier (or null)
state.createdAt // Creation timestamp
state.arguments // Invocation or subagent creation argumentsgetMessages
Retrieve message history from the thread:
const { messages, total, hasMore } = await state.getMessages({
limit: 100, // Max messages
offset: 0, // Skip count
order: 'desc', // 'asc' or 'desc'
includeSilent: false, // Exclude messages hidden from the UI
maxDepth: 0 // Max nesting depth (0 = top-level only)
});File System
Access the thread's file system:
// Read/write files
const data = await state.readFile('/data/config.json');
if (data) {
const config = JSON.parse(new TextDecoder().decode(data));
await state.writeFile('/output/result.json', JSON.stringify(config), 'application/json');
}
// List and search
const { entries } = await state.readdirFile('/data');
const { paths } = await state.findFiles('**/*.json');
const matches = await state.grepFiles('error');Resource Loading
Load agent, prompt, or model definitions:
const agent = await state.loadAgent('my-agent');
const prompt = await state.loadPrompt('my-prompt');
const model = await state.loadModel('gpt-6.1-sol');Request Handling
Reading Request Body
export default defineThreadEndpoint(async (req, state) => {
// Read the body once with the method for your content type.
const body = await req.json();
// For form data, use await req.formData() instead.
// For plain text, use await req.text() instead.
// ...
});Query Parameters
export default defineThreadEndpoint(async (req, state) => {
const url = new URL(req.url);
const limit = parseInt(url.searchParams.get('limit') || '10');
const format = url.searchParams.get('format') || 'json';
const { messages } = await state.getMessages({ limit });
// ...
});Headers
export default defineThreadEndpoint(async (req, state, _params, auth) => {
const requestId = req.headers.get('X-Request-ID');
return Response.json({
requestId,
threadId: state.threadId,
callerId: auth.caller?.id,
threadAccess: auth.threadAccess,
});
});Error Handling
Standard Agents Builder returns JSON for every failed request under /api/.
The response preserves the HTTP status.
The body uses { "error": { "code": "internal_error", "message": "..." }, "requestId": "..." }.
The X-Request-ID header matches requestId.
Failed responses use Cache-Control: no-store.
An uncaught exception returns HTTP 500 with code internal_error.
The server log contains the exception and request ID.
The response message omits exception details.
Authentication infrastructure failures return HTTP 503 with code auth_unavailable.
Platform network failures, unexpected HTTP statuses, and invalid payloads return HTTP 502 with code platform_unreachable.
Check PLATFORM_ENDPOINT and platform availability after that error.
An explicit platform credential or membership denial uses code platform_auth_rejected during local bootstrap.
Unknown API paths return HTTP 404 with code not_found.
Read error.message to display a failure.
Include requestId in a support report.
Returned legacy JSON errors retain their extra fields and response headers.
The router converts their error field to the structured envelope.
A legacy string error with a separate message retains its code in the envelope.
Its separate message becomes the envelope message.
WebSocket upgrades and successful responses retain their existing behavior.
This error contract is a Standard Agents Builder extension.
Handlers can return domain errors with an appropriate HTTP status:
return Response.json({ error: 'Invalid JSON body' }, { status: 400 });Let unexpected exceptions reach the runtime boundary.
Best Practices
Keep API Handlers Fast
API handlers run in the request path. Keep operations quick:
// Good - paginated query
const { messages } = await state.getMessages({ limit: 50 });
// Avoid - loading entire history for large threads
const { messages } = await state.getMessages({ limit: 10000 });Handle Errors Gracefully
Always wrap risky operations:
try {
const data = await req.json();
// Process data...
} catch (error) {
return Response.json(
{ error: 'Invalid JSON body' },
{ status: 400 }
);
}Validate Input Data
Check request data before processing:
const { format, limit } = await req.json();
if (!['json', 'markdown', 'csv'].includes(format)) {
return Response.json(
{ error: 'Invalid format. Use: json, markdown, or csv' },
{ status: 400 }
);
}
if (limit && (limit < 1 || limit > 1000)) {
return Response.json(
{ error: 'Limit must be between 1 and 1000' },
{ status: 400 }
);
}Use Descriptive File Names
Name files clearly to indicate their purpose:
agents/api/
├── summary.get.ts # Clear: GET summary
├── export.post.ts # Clear: POST to export
├── analytics.get.ts # Clear: GET analytics
└── data.ts # Unclear: what does this do?