Expose HTTP endpoints from a plugin. A route lets external systems (webhooks, integrations, callbacks, small tools) reach the Assistant over HTTP inside the plugin's own namespace.
A route is a file under routes/<path>.tsthat exports named HTTP-method functions. There is no registration step and no manifest entry: the Assistant's /x/*route dispatcher resolves each request against the plugin's routes/ directory on disk at request time.
Every plugin route lives in a namespace reserved for that plugin:
/x/plugins/<plugin-name>/<path>The plugins/<name>/ prefix resolves only against <workspaceDir>/plugins/<name>/routes/. It never falls back to a workspace routes/plugins/…file, so a plugin can't collide with workspace routes or with another plugin. A path with no matching file returns 404, and a disabled plugin (its .disabled sentinel present) serves no routes even though the files remain on disk.
The same file-based dispatcher also serves standalone workspace routes at /x/<path> from <workspaceDir>/routes/. Plugin routes are the namespaced form of that surface; a plugin is what lets you ship routes together with its other surfaces as one installable unit.
The file's path under routes/ becomes the sub-path, minus the extension. Nested directories nest, and an index file maps to the directory itself:
| File | Served at |
|---|---|
routes/status.ts | /x/plugins/<name>/status |
routes/webhooks/incoming.ts | /x/plugins/<name>/webhooks/incoming |
routes/index.ts | /x/plugins/<name> (the namespace root) |
.js wins over .ts for the same basename (compiled-binary semantics), and a direct file wins over an index file for the same path.
Each file exports one function per HTTP method it accepts (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS), using the standard Web API Request/Response signature. A request whose method the file does not export returns 405 with an Allow header listing the methods it does.
export async function GET(request: Request): Promise<Response> {
return Response.json({ ok: true });
}
export async function POST(request: Request): Promise<Response> {
const body = await request.json();
return Response.json({ received: body }, { status: 201 });
}Handlers receive a second contextargument with the Assistant's runtime singletons (the event hub, and a conversations.postMessage helper for surfacing an inbound event as a real turn):
export async function POST(request: Request, context): Promise<Response> {
const { conversationId, text } = await request.json();
await context.conversations.postMessage(conversationId, text);
return Response.json({ delivered: true });
}A handler may also reach other Assistant capabilities through its @vellumai/plugin-api imports, the same as any other surface.
Route files are loaded lazily on the first matching request and cached by path + mtime. Editing a route file is picked up on the next request; the dispatcher re-reads it when its mtime changes, so there is no restart or reload step. A handler that throws returns 500; a handler that runs longer than the per-request timeout (30s) returns 504.
my-plugin/
└── routes/
├── index.ts → GET /x/plugins/my-plugin
├── status.ts → GET, POST /x/plugins/my-plugin/status
└── webhooks/
└── incoming.ts → POST /x/plugins/my-plugin/webhooks/incoming// routes/status.ts
export async function GET(request: Request): Promise<Response> {
return Response.json({ status: "ok", uptimeMs: performance.now() });
}Reach for a route when something outside the assistant needs to reach inover HTTP: a webhook from a third-party service, an OAuth callback, an integration that posts events, or a small status endpoint. Unlike a tool (which the model calls) or a hook (which fires inside the turn), a route is driven by an external caller and runs whenever a request arrives. Bundle it in a plugin when you want that endpoint to ship, version, and install alongside the plugin's other surfaces.