Proxy Options
Every option of createNeocortexHandler, the routes it serves and the errors it returns
createNeocortexHandler(options) from @neocortex-link/web/server returns a standard (Request) => Promise<Response> handler. It throws at startup if apiKey, authorize or characters is missing.
import { createNeocortexHandler } from "@neocortex-link/web/server";
const handler = createNeocortexHandler({
apiKey: process.env.NEOCORTEX_API_KEY!,
characters: ["your-character-id"],
authorize: async (request) => {
const user = await getUser(request);
return user ? { id: user.id } : null;
},
buildContext: async (user) => ({
events: [{ priority: 1, content: `${user.id} has ${await coins(user.id)} coins` }],
}),
});Options
| Option | Default | Description |
|---|---|---|
apiKey | required | Your secret team key. Read it from the environment. It never leaves the server. |
authorize(request) | required | Identifies the visitor with your own auth. Return { id }, or null to refuse with 401. The id becomes the Neocortex playerId. |
characters | required | The characters the browser may talk to, as an array of ids or (user) => ids. Anything else is refused with 403. |
questionSets | any of the team's sets | The question sets the browser may start quizzes with, as an array or (user) => ids. |
basePath | /api/neocortex | Where the handler is mounted. Use "" when it owns the whole host. |
baseUrl | https://api.neocortex.link/v3 | The Neocortex API. Point it at a local or staging API. |
routes | see Routes | Turns individual routes on or off, for example { transcribe: false }. |
limits | see Limits | Overrides individual limits. |
rateLimit | 30 requests a minute per user, in memory | { limit, windowSeconds } for the built-in limiter, a RateLimiter backed by a shared store, or false to turn it off. |
speechRepliesOnly | true | Only voice lines a character actually said to this user. false lets the browser voice any text in a character's voice. |
allowClientContext | false | Accept scene metadata and events sent from the browser. |
buildContext(user, turn) | Adds trusted scene context to every chat turn. turn is { characterIds, message, request }. Return { metadata?, events? }. | |
filterText(text, context) | Rewrites what characters say before it reaches the browser, for example for moderation. context is { user, route }, where route is "chat" or "quiz". | |
cors | none | { origins, allowHeaders? }. Only needed when the page is on another origin. allowHeaders adds request headers to the default content-type and authorization. |
secret | derived from apiKey | Signs session and speech tokens. Set it to keep tokens valid across key rotations. |
timeoutMs | 60000 | Milliseconds to wait for Neocortex to start answering. |
fetch | globalThis.fetch | A custom fetch for upstream calls. |
onError(error, context) | logs with console.error | Called for failures worth logging. context is { route?, userId?, status }. Never receives the API key. |
Routes
Each route is a path under basePath that maps to one Neocortex endpoint.
| Route | Proxy path | Method | Neocortex endpoint | Default |
|---|---|---|---|---|
chat | /chat | POST | POST /chat | on |
history | /chat/session | POST | POST /chat/session | on |
speech | /audio/generate | POST | POST /audio/generate | on |
speechStream | /audio/stream | POST | POST /audio/stream | on |
transcribe | /audio/transcribe | POST | POST /audio/transcribe | on |
quiz | /quiz | POST | POST /quiz | on |
quizSession | /quiz/session | GET | GET /quiz/session | on |
usage | /usage | GET | GET /usage | on |
characters | /characters | GET | GET /characters | off |
questionSets | /question-sets | GET | GET /question-sets | off |
account | /account | GET | GET /account | off |
characters, questionSets and account are off by default because they list your whole team's content, and account includes your team's email address. When on, characters and questionSets are filtered by your allowlists.
With the default basePath, the chat route is POST /api/neocortex/chat. OPTIONS requests to any path return 204, with CORS headers for origins in cors.origins.
Limits
| Limit | Default | Description |
|---|---|---|
messageChars | 500 | Longest player message, in characters. Also applies to quiz answers. |
speechChars | 2000 | Longest text for speech synthesis, in characters. The API's own cap is 2000. |
jsonBytes | 65536 (64 KB) | Largest JSON body, in bytes. |
audioBytes | 10485760 (10 MB) | Largest audio upload for transcription, in bytes. The API's own cap is 10 MB. |
charactersPerTurn | 6 | Most characters in one group turn. maxTurns is clamped to this too. |
metadataItems | 40 | Most scene entities a turn may carry. Extra items are cut from the end, after the server's own. |
eventItems | 20 | Most events a turn may carry. The oldest are cut first, so the server's own events are kept. |
createNeocortexHandler({
// ...
limits: { messageChars: 300, charactersPerTurn: 3 },
rateLimit: { limit: 60, windowSeconds: 60 },
});Errors
Every refusal is JSON in the form { "error": "...", "code": "..." }. In the browser it arrives as a NeocortexError with the same status and code.
These are the codes the proxy returns itself:
| Status | Code | When |
|---|---|---|
400 | invalid_json | The body is not valid JSON, or not a JSON object. |
400 | invalid_form | The transcription upload could not be read. |
401 | unauthorized | authorize returned no user, or threw. |
403 | origin_not_allowed | The request came from another site or a sibling subdomain, and its origin is not in cors.origins. |
403 | character_not_allowed | The character is not on your characters allowlist. |
403 | question_set_not_allowed | The question set is not on your questionSets allowlist. |
403 | speech_not_allowed | The text to speak has no valid speech token for this user and character. |
404 | not_found | Unknown path, or a route that is turned off. |
404 | session_not_found | The session token is malformed, belongs to another user, or was signed with another secret (history, quiz turns and quiz state). |
405 | method_not_allowed | Wrong method for the route. The Allow header names the right one. |
409 | operation_in_progress | A turn is already running for this conversation, or a quiz start for this user. Comes with Retry-After: 2. |
413 | payload_too_large | The body or the recording is larger than jsonBytes or audioBytes. |
415 | unsupported_media_type | A JSON route was called without Content-Type: application/json, or transcription without multipart/form-data. |
422 | invalid_character | characterId is missing or not a valid id. |
422 | invalid_characters | A chat turn has no character ids, or more than charactersPerTurn. |
422 | invalid_field | A field has the wrong type, is empty when required, or speakerCharacterId is not one of characterIds. |
422 | too_long | A message, speech text or other text field is over its limit. |
422 | missing_ids | A quiz was started without a questionSetId. |
429 | rate_limited | The user is over the rate limit. Comes with Retry-After in seconds. |
500 | internal_error | Something unexpected failed inside the proxy, for example in your buildContext. Details go to onError. |
502 | proxy_misconfigured | Neocortex refused the API key, or the key was deactivated or its workspace suspended. Details go to onError. |
502 | upstream_error | Neocortex returned a server error. Details go to onError. |
502 | upstream_unreachable | Neocortex could not be reached, or did not start answering within timeoutMs. |
Errors from Neocortex itself with status 400, 402, 403, 404, 409, 413, 422 or 429 are passed through with their message, their code (upstream_error when Neocortex sent none) and any Retry-After header, for example insufficient_credits or the quiz codes listed in Quiz. A finished quiz's closing turn is passed through too, with its session token and speech tokens signed. Every other upstream status becomes 502 upstream_error.