Channels API#
Inbound surfaces for the embedded agent. The concept and the reasoning are in the channels guide; this page is the surface.
Every file under src/channels/ is a channel named by its relative path, served at /_janux/channels/<name>. _-prefixed files are shared code.
defineChannel#
import { defineChannel } from '@janux/agent';
export default defineChannel({
receive: async (req) => ({ text: ((await req.json()) as { text: string }).text, threadId: 'ops' }),
send: (reply) => Response.json(reply),
});Identity at runtime, like defineSchedule — it exists to type the object. A channel implements ChannelDef:
| Member | Type | What it does |
|---|---|---|
receive |
(req) => ChannelMessage | Response | undefined |
Parses the transport. A Response answers it directly (handshake, rejected signature); undefined ignores the event with a 204. Both skip the model entirely. |
send |
(reply: ChannelReply) => Response |
Renders the finished turn for the transport. |
ChannelMessage is { text, threadId?, path? } — threadId keys harness memory, path chooses the page whose manifest frames the turn (default /).
ChannelReply is { text, threadId?, error?, detail? }. error is set when the turn ended in a guardrail refusal, a provider failure or missing model setup, so a transport can style it differently. detail carries the provider's own words when there are any — it travels so an operator can diagnose without reading server logs, and it is separate from text precisely so a chat transport can show the sentence and not the stack: slackChannel and discordChannel render text alone.
webhookChannel#
import { webhookChannel } from '@janux/agent';
export default webhookChannel({ secret: process.env.JANUX_WEBHOOK_SECRET! });Bearer in, JSON out — the transport with no third party in it.
curl -X POST localhost:3000/_janux/channels/webhook \
-H "authorization: Bearer $JANUX_WEBHOOK_SECRET" \
-d '{"text":"how many orders are pending?","threadId":"ops-1"}'| Option | Type | Notes |
|---|---|---|
secret |
string |
Required in authorization: Bearer. Unset ⇒ every caller gets 503 channel_unconfigured. |
Accepts { text, threadId?, path? }; answers { text, threadId?, error? }. A non-POST is 405, a wrong bearer 401, and an empty text is ignored with 204.
slackChannel#
import { slackChannel } from '@janux/agent';
export default slackChannel({ signingSecret: process.env.SLACK_SIGNING_SECRET! });Speaks slash commands: a signed form POST that takes its answer on the same response, so there is no bot token and no outbound client. Point a command's Request URL at /_janux/channels/<name>.
| Option | Type | Notes |
|---|---|---|
signingSecret |
string |
Basic Information → App Credentials. |
toleranceSeconds |
number |
How old a signed request may be. Default 300, Slack's own guidance. |
Verifies Slack's v0 HMAC-SHA256 over the raw body and rejects requests older than the tolerance, so a captured request stops working. Threads by Slack channel (slack:C123). Answers in_channel, or ephemeral when the reply carries an error.
discordChannel#
import { discordChannel } from '@janux/agent';
export default discordChannel({ publicKey: process.env.DISCORD_PUBLIC_KEY! });Speaks the interactions endpoint: an Ed25519-signed JSON POST answered on the same response — no gateway socket, no bot process.
| Option | Type | Notes |
|---|---|---|
publicKey |
string |
General Information → Public Key, hex. |
Answers Discord's PING handshake itself, so verifying the endpoint never reaches the model. Takes the question from the command's first string option, threads by Discord channel (discord:C77), and replies as a channel message — flagged ephemeral when the reply carries an error.
Server seam#
Exported from @janux/server for hosts that wire their own server (custom server); janux dev and janux start do this for you.
| Export | Signature | What it is |
|---|---|---|
CHANNELS_PREFIX |
'/_janux/channels/' |
The mount prefix. |
channelOf |
(pathname) => string | undefined |
The channel name in a path, if it is one. |
handleChannel |
(req, name, channel, agent, deps) => Promise<Response> |
One request end to end: receive, the ordinary agent turn, send. |
ServerOptions.channels is Record<string, ChannelDef>. The mount runs agent.handle with the same AgentDeps the browser endpoint gets, which is what makes the guards identical on both doors.
Discovery#
Exported from @janux/vite/config for adapters and custom builds.
| Export | Signature |
|---|---|
channelFiles |
(channelsDir) => string[] |
channelName |
(channelsDir, filePath) => string |
channelServerOptions |
(app, load) => Promise<ServerOptions['channels']> |
What a channel does not have#
The client half of the tool surface — ui_* and your components' own intents — needs a browser. On a channel turn it is withheld, the system prompt names every withheld tool, and a call that reaches for one returns:
{
"error": "tool_unavailable_on_channel",
"tool": "ui_navigate",
"channel": "webhook",
"message": "\"ui_navigate\" drives the browser UI and this turn is on the \"webhook\" channel, so nothing ran. …"
}The turn continues from there, so it ends in a sentence the human can read rather than a call that failed under it.