JavaScript API
Drive Neocortex characters from your own UI or game loop
The objects behind the components, for your own UI or game loop. Names mirror the Unity SDK (SmartAgent is NeocortexSmartAgent, and so on).
Entry points
| Import from | Contains |
|---|---|
@neocortex-link/web/client | SmartAgent, GroupDirector, QuizAgent, AudioReceiver, StreamingAudioPlayer, UsageGate, EventLogger, Interactables, LocalSessionStore, MemorySessionStore, primeAudio, getAudioContext |
@neocortex-link/web | NeocortexClient, NeocortexError, isNeocortexError, EMOTIONS, toEmotion, VERSION and the API types |
@neocortex-link/web/server | createNeocortexHandler, toNodeHandler, MemoryRateLimiter, NeocortexClient, NeocortexError, isNeocortexError |
@neocortex-link/web/ui | The element classes, defineNeocortexElements, setDefaultClient, and everything from @neocortex-link/web |
Every class takes endpoint (default /api/neocortex) or a client, which wins.
Example: voice in, voice out
import { SmartAgent, AudioReceiver } from "@neocortex-link/web/client";
const agent = new SmartAgent({ endpoint: "/api/neocortex", characterId: "your-character-id" });
agent.on("lineStarted", ({ line }) => subtitle.show(line.text, line.emotion));
agent.on("emotionChanged", ({ emotion }) => avatar.setExpression(emotion));
agent.on("composing", (thinking) => spinner.toggle(thinking));
agent.registerAction("FOLLOW_PLAYER", () => npc.follow(player));
// Typed input, spoken reply
sendButton.onclick = () => {
agent.primeAudio(); // browsers only allow audio after a click
agent.textToAudio(input.value);
};
// Spoken input, spoken reply
const mic = new AudioReceiver({ maxWaitTime: 1.2 });
mic.on("recorded", (wav) => agent.audioToAudio(wav));
talkButton.onclick = async () => {
agent.primeAudio();
await mic.start(); // asks for microphone permission, then records when the player speaks
};Speech streams line by line, prefetching the next line. A line that fails to synthesize is shown as text. Input during a turn is queued. Call primeAudio() from a user gesture so later speech can play.
Events
Every class except EventLogger and Interactables is an emitter:
const off = agent.on("lineStarted", (detail) => { /* ... */ }); // returns a function that removes the handler
agent.once("replyFinished", (reply) => { /* ... */ });
agent.off("lineStarted", handler);A throwing handler is logged and doesn't stop the others.
SmartAgent
One character in conversation with the player.
const agent = new SmartAgent({ endpoint: "/api/neocortex", characterId: "your-character-id", voice: true });
await agent.send("Where is the blacksmith?");Conversation options
SmartAgent and GroupDirector share these options:
| Option | Default | Description |
|---|---|---|
endpoint | /api/neocortex | Your proxy route. |
client | A configured NeocortexClient. Takes precedence over endpoint. | |
voice | false | Speak replies aloud. Can also be chosen per call. |
paceLines | true | Show multi-line replies one line at a time. |
actionTrigger | "whenResponseReceived" | When registered actions run: "whenResponseReceived", "whenSpeechStarts" or "afterReplySpoken". |
sessionStore | LocalSessionStore | Persists the session between page loads. Pass null to start fresh every time, or a MemorySessionStore to forget it when the page closes. |
interactables | An Interactables whose entities are sent with each turn. | |
eventLogger | An EventLogger whose events are sent with each turn. | |
language | Language for speech and transcription, for example "en" or "tr". | |
player | a new StreamingAudioPlayer | The player that plays speech. |
LocalSessionStore uses the Unity SDK's keys (neocortex-session-id_<characterId>) and falls back to memory where localStorage is unavailable. An unknown session is cleared and the next turn starts fresh.
Methods
| Method | Description |
|---|---|
textToText(message) | Sends a typed message and shows the reply as text. |
textToAudio(message) | Sends a typed message and speaks the reply. |
audioToText(audio) | Transcribes a recording, sends it, and shows the reply as text. |
audioToAudio(audio) | Transcribes a recording, sends it, and speaks the reply. |
send(message) | Sends a message using the agent's voice setting. |
prompt() | Lets the character speak without the player saying anything, for example a greeting. |
transcribe(audio) | Transcribes a recording only. Resolves with the text, or null. |
history(limit = 20, before?) | Loads one page of this conversation's history, newest page first. Pass the page's nextCursor as before for the next. Resolves with null when there is no conversation yet. |
registerAction(name, handler) | Runs handler(action, message) whenever the character asks for the action name. Names are matched case-insensitively. Returns a function that removes the handler. |
unregisterAction(name) | Removes a handler. |
clearSession() | Forgets the conversation. The next turn starts a new session. |
abort() | Cancels the turn in flight and anything queued, and stops speech. |
primeAudio() | Unlocks audio output. Call it from a click or key press. |
The send methods resolve with the character's message { characterId, name, lines, actions } once it has been played, or null if the turn failed. Failures are reported through the error event, not thrown.
Properties
characterId, sessionId, isBusy, isSpeaking, client, player, and the settable voice, paceLines, actionTrigger and language.
Events
| Event | Payload |
|---|---|
busy | true while a turn is in flight or being played. |
composing | true while waiting on the server or between paced lines. Show a thinking indicator. |
playerMessage | What the player said, typed or transcribed. |
transcription | The text of a transcribed recording. |
response | The whole reply { sessionId, messages }, as soon as it arrives. |
speaker | { characterId, name }, when a character begins its part of the reply. |
lineStarted | { characterId, name, line, index }. line is { text, emotion, spokenText?, speechToken? }. |
emotionChanged | { characterId, emotion } |
action | { characterId, action, message }, for every action, with or without a handler. |
message | A character finished its part of the reply. |
replyFinished | Every character has finished. |
history | A page of history loaded by history(). |
error | A NeocortexError. |
Emotions are camelCase: neutral, happy, pleased, disappointed, upset, amazed, curious, confused, alarmed, fascinated, impressed, annoyed, angry, confident, reassured, concerned, scared. Anything else is read as neutral.
GroupDirector
Several characters in one conversation, where the AI picks who answers. See Group Chat for a walkthrough.
import { GroupDirector } from "@neocortex-link/web/client";
const group = new GroupDirector({ endpoint: "/api/neocortex", characterIds: ["aria", "bram"], voice: true });
group.on("speaker", ({ name }) => highlight(name));
await group.send("What happened at the mill?");Takes the conversation options, plus characterIds and an optional maxTurns (how many characters may speak in one turn). The proxy allows up to 6 characters a turn by default.
| Method | Description |
|---|---|
send(message) | The player speaks to the group, and the AI decides who answers. |
sendTo(characterId, message) | The player addresses one character directly. |
continue() | The characters carry on among themselves without the player. |
sendAudio(audio) | Transcribes a recording and sends it to the group. |
addCharacter(characterId) | Adds a character. The cast is part of the session, so this starts a new one. |
removeCharacter(characterId) | Removes a character and starts a new session. The last character cannot be removed. |
The send methods resolve with the whole reply { sessionId, messages }, or null. members lists the current cast. Everything else (events, registerAction, history, abort and so on) works as on SmartAgent.
QuizAgent
A quiz host that asks questions, judges answers and keeps score (Team plan). See Quiz for a walkthrough.
import { QuizAgent } from "@neocortex-link/web/client";
const quiz = new QuizAgent({ endpoint: "/api/neocortex", characterId: "host-id", questionSetId: "set-id", voice: true });
quiz.on("hostLine", ({ line }) => subtitle.show(line.text));
quiz.on("questionChanged", (question) => renderOptions(question?.options ?? []));
quiz.on("finished", () => showResults(quiz.leaderboard));
startButton.onclick = () => {
quiz.primeAudio();
quiz.begin();
};| Option | Default | Description |
|---|---|---|
characterId | required | The host. |
questionSetId | required | The question set to play. |
endpoint / client | As above. | |
participants | [{ id, displayName, type? }], where type is "human" or "ai". The proxy passes on the first 10. | |
questionCount | How many questions to ask. | |
selectionMode | "sequential" or "random". | |
voice | false | Speak the host's lines aloud. |
queueInputWhileBusy | true | Hold input that arrives mid-turn and send it afterwards. When false, it is dropped with an inputDropped event. |
language | Language for speech. | |
player | a new StreamingAudioPlayer | The player that plays speech. |
playerId | Ignored behind the proxy, which always uses the signed-in user's id. |
| Method | Description |
|---|---|
begin(participants?) | Starts a new quiz, replacing any current one. |
say(text, { typed }) | The player answers, asks for a hint, says "repeat" and so on, in their own words. Set typed: true when the text was typed rather than spoken. |
sayAs(participantId, text, { typed }) | The same, for one participant. |
choose(optionId) / chooseAs(participantId, optionId) | Picks one of the question's options. |
pass() / passAs(participantId) | Skips the question. |
resync() | Re-reads the quiz state from the server, for example after a reconnect. |
abort() | Cancels the turn in flight and stops speech. |
primeAudio() | Unlocks audio output. |
The state of the run is kept on the agent: sessionId, question, progress ({ asked, total }), expecting ("answer", "confirmation" or "nothing"), leaderboard, lastResult, lastIntent, attempt, attemptsLeft, hintGiven, pending, answered, isFinished and isBusy.
| Event | Payload |
|---|---|
busy | true while a turn is running. |
hostLine | { line, index }, when a host line appears. |
emotionChanged | The host's new emotion. |
hostFinished | The turn, once the host has finished its lines. |
questionChanged | The new question, after the host has read it out, or null when the quiz ends. |
upcoming | { questionId, keyword? } for the next question. |
expectingChanged | What the host is waiting for. |
result | The verdicts for the last answer. |
leaderboardChanged | The standings. |
turn | Every turn, as the server sent it. |
finished | The closing turn. |
inputDropped | { reason }: "busy", "finished" or "not_started". |
creditsExhausted | The team ran out of credits. |
error | A NeocortexError. |
AudioReceiver
Records the player's voice with automatic speech detection or push-to-talk, on an AudioWorklet. In detection mode it listens continuously, starts recording when loudness crosses the threshold, and finishes after maxWaitTime seconds of silence. Recordings come out as 16 kHz mono WAV, ready for audioToAudio.
import { AudioReceiver } from "@neocortex-link/web/client";
const mic = new AudioReceiver({ pushToTalk: true });
mic.on("recorded", (wav) => agent.audioToText(wav));
talkButton.onpointerdown = () => mic.press();
talkButton.onpointerup = () => mic.release();| Option | Default | Description |
|---|---|---|
pushToTalk | false | Record only between press() and release(), instead of detecting speech. |
amplitudeThreshold | 0.1 | Loudness from 0 to 1 that counts as speech. |
maxWaitTime | 1.2 | Seconds of silence that end a recording. |
maxRecordingSeconds | 30 | Longest recording. |
keepListening | false | Keep listening for the next phrase after one is recorded. |
deviceId | the last selected device | A specific input device. |
| Member | Description |
|---|---|
start() | Opens the microphone. In detection mode, recording begins when the player speaks. |
stop() | Closes the microphone, including one still waiting on permission. A recording in progress is discarded. |
press() / release() | Push-to-talk: start recording, then stop and emit it. press() opens the microphone if needed. |
selectDevice(deviceId) | Switches microphone and remembers the choice for next time. |
AudioReceiver.devices() | Lists microphones. Labels are only filled in once permission has been granted. |
AudioReceiver.isSupported() | Whether this browser can record. |
state, amplitude, isListening, isRecording, silenceProgress | Current state. silenceProgress goes from 0 to 1 as silence approaches maxWaitTime, for a countdown ring. |
| Event | Payload |
|---|---|
recorded | The recording as a WAV Blob. Recordings shorter than 0.2 seconds are dropped. |
speechStarted | Recording began. |
state | "idle", "listening" or "recording". |
amplitude | Microphone loudness from 0 to 1, about 20 times a second. |
permissionGranted | The player allowed the microphone. |
permissionDenied | The player blocked the microphone. |
error | Anything else that went wrong. |
StreamingAudioPlayer
Plays a character's voice while it is still being synthesized, through Web Audio. Every agent has one as its player, so you can read the speech level for lip sync:
function animate() {
avatar.mouthOpen = agent.player.getLevel(); // 0 to 1
requestAnimationFrame(animate);
}
animate();| Member | Description |
|---|---|
new StreamingAudioPlayer({ prebufferSeconds }) | prebufferSeconds defaults to 0.15, so a slow first block does not stutter. |
play(chunks, { onStart, signal }) | Plays streamed PCM chunks, such as stream.chunks from client.streamAudio(). Resolves once playback ends or is stopped. |
playBlob(blob, { onStart, signal }) | Plays a whole clip, such as the result of client.generateAudio(). |
stop() | Stops playback. |
getLevel() | How loud the speech is right now, from 0 to 1. |
volume | Output volume from 0 to 1. |
isPlaying | Whether anything is playing. |
prime() | Unlocks audio output. Call it from a user gesture. |
Pass one player to several agents with the player option to share one volume. A player plays one thing at a time, so a new line stops whatever it was playing.
UsageGate
Checks credits before offering a conversation, so you can fall back to scripted lines. Fails open.
import { UsageGate } from "@neocortex-link/web/client";
const gate = new UsageGate({ endpoint: "/api/neocortex", characterId: "your-character-id" });
await gate.refresh();
if (gate.canUse()) await agent.send(text);
else showScriptedLine();| Member | Description |
|---|---|
new UsageGate({ endpoint, client, characterId, minRefreshSeconds }) | minRefreshSeconds defaults to 30. Calls inside that window reuse the last answer. |
refresh(force = false) | Checks usage and resolves with it, or with the last answer if the check failed. |
canUse() | false only when the last check said credits, or this player's or character's allowance, ran out. |
startAutoRefresh(seconds) / stopAutoRefresh() | Refreshes on an interval, at least every 5 seconds. |
usage | The last answer: { status, creditsRemaining, player?, character? }, where status is "ok", "low" or "empty". |
Events: updated, lowCredits, creditsEmpty, playerOverLimit, characterOverLimit, each with the usage, and error when the check itself failed.
Behind the proxy, usage is checked for the signed-in user. Caps are set on the API Keys page.
EventLogger
Collects game events for characters to know about, such as "the player opened the vault". Each entry is cut to 64 characters, and a turn sends at most 20, highest priority first, then newest.
import { EventLogger, SmartAgent } from "@neocortex-link/web/client";
const events = new EventLogger();
const agent = new SmartAgent({ endpoint: "/api/neocortex", characterId: "guard", eventLogger: events });
events.push("The player picked the vault lock", 2);| Method | Description |
|---|---|
push(content, priority = 1, date = new Date()) | Adds an event. priority is 0 (low), 1 (medium) or 2 (high). |
getLogs(limit = 20) | The events a turn would send. |
clear() | Removes every event. |
Interactables
The things in a scene a character can perceive and act on. Actions come back with the targetId of the entity they apply to. See Interactables for a walkthrough.
import { Interactables, SmartAgent } from "@neocortex-link/web/client";
const scene = new Interactables()
.set({ id: "door-1", type: "OBJECT", name: "Cellar door", properties: [{ name: "locked", value: "true" }] })
.set({ id: "lamp-1", type: "OBJECT", name: "Oil lamp" })
.focus("door-1");
const agent = new SmartAgent({ endpoint: "/api/neocortex", characterId: "guard", interactables: scene });
agent.registerAction("UNLOCK", (action) => unlock(action.targetId));| Method | Description |
|---|---|
set(item) | Adds or replaces an entity: { id, type, name, characterId?, isSubject?, position?, properties? }, where type is "CHARACTER" or "OBJECT". |
update(id, patch) | Changes some fields of an entity. |
remove(id) / get(id) / clear() | Manage entities. |
focus(id) | Marks one entity as what the player is focused on (isSubject), and clears the rest. Pass null to clear all. |
toMetadata() | The entities as sent with a turn. |
set, update, remove and focus return the same object, so calls can be chained.
The proxy drops browser metadata and events unless allowClientContext: true.
NeocortexClient
A typed wrapper over every v3 endpoint. Create one to add headers, change timeouts, or call endpoints directly.
import { NeocortexClient } from "@neocortex-link/web";
import { SmartAgent } from "@neocortex-link/web/client";
const client = new NeocortexClient({
baseUrl: "/api/neocortex",
headers: () => ({ authorization: `Bearer ${getToken()}` }),
});
const agent = new SmartAgent({ client, characterId: "your-character-id" });| Option | Default | Description |
|---|---|---|
baseUrl | /api/neocortex, or https://api.neocortex.link/v3 with an apiKey | Where requests go. |
apiKey | The secret team key. Only for servers, or browser prototyping with dangerouslyAllowBrowser. | |
dangerouslyAllowBrowser | false | Allows an apiKey in browser code. See Prototyping without a server. |
headers | Extra headers for every request, as an object or a function called per request. | |
credentials | "same-origin" | Cookie policy for requests. Use "include" for a proxy on another origin that relies on cookies. |
fetch | globalThis.fetch | A custom fetch. |
timeouts | { chat: 30000, audio: 20000, lookup: 15000 } | Milliseconds to wait for a response to start. |
| Method | Endpoint |
|---|---|
chat(request) | POST /chat |
chatHistory({ sessionId, limit?, before? }) | POST /chat/session |
generateAudio({ characterId, message, emotion?, format?, language?, speechToken? }) | POST /audio/generate, resolves with an audio Blob |
streamAudio({ characterId, message, emotion?, language?, speechToken? }) | POST /audio/stream, resolves with { chunks, cancel() } |
transcribe({ characterId, audio, language?, prompt? }) | POST /audio/transcribe, resolves with { response } |
quizTurn(request) | POST /quiz |
quizSession(sessionId) | GET /quiz/session |
usage({ playerId?, characterId? }) | GET /usage |
account() | GET /account |
characters() | GET /characters |
questionSets() | GET /question-sets |
Every method takes an optional second argument { signal, timeoutMs }. quizTurn retries the server's "still busy" refusals the same way the Unity SDK does.
Server-to-server
On a backend that calls Neocortex directly, give the client your key. baseUrl then defaults to the Neocortex API:
import { NeocortexClient } from "@neocortex-link/web/server";
const neocortex = new NeocortexClient({ apiKey: process.env.NEOCORTEX_API_KEY });
const { sessionId, messages } = await neocortex.chat({
characterIds: ["your-character-id"],
message: "Hi",
playerId: "user-1",
});Errors
Every failure is a NeocortexError. Agents emit it on error; NeocortexClient throws it.
| Member | Description |
|---|---|
status | The HTTP status, or 0 when no response arrived. |
code | The machine-readable reason, when there is one. Branch on this. |
message | A readable description. |
retryAfter | Seconds to wait before retrying, when the server said so. |
turn | For quiz_finished, the closing turn the quiz already played. |
isOutOfCredits | true when the team has run out of credits, so retrying will not help. |
isAborted | true when the request was cancelled. |
Codes raised in the browser are timeout, network_error, aborted, client_error and browser_api_key. Codes from the proxy are listed in Proxy Options.
Use isNeocortexError(error) rather than instanceof, so errors from a second copy of the package (CJS next to ESM) match too.
import { isNeocortexError } from "@neocortex-link/web";
try {
await client.chat({ characterIds: ["your-character-id"], message: "Hi" });
} catch (error) {
if (isNeocortexError(error) && error.code === "rate_limited") {
await wait((error.retryAfter ?? 5) * 1000);
}
}