Host server
Serve your agents over HTTP, chat channels and schedules from one small server.
An agent runs fine as a plain library call. When you want it reachable from Slack, WhatsApp, GitHub, a cron schedule or your own frontend, wrap it in a host. One host() call gives you:
- webhooks for each channel at
/channels/<name>/events - cron schedules
- a typed HTTP API with streaming (
/v1/...) - crash recovery: messages are stored before they are acknowledged, and a restart picks up where it stopped
Define a host
import { secret, sqlite } from "@threads/core";
import { host } from "@threads/host";
import { slack } from "@threads/slack";
export default host({
store: sqlite(".threads"),
agents: { support },
channels: {
slack: slack({
agent: "support",
signingSecret: secret("SLACK_SIGNING_SECRET"),
botToken: secret("SLACK_BOT_TOKEN"),
}),
},
});support is any agent made with agent(). Its key in agents is the name that channels, schedules and the HTTP API use to reach it.
Options
storeStorerequiredWhere every thread's log lives. sqlite(".threads") is a directory on disk. Use sqlite(":memory:") in tests.
agentsRecord<string, Agent>requiredThe agents this host serves, by name.
channelsRecord<string, ChannelAdapter>schedulesSchedule[]Cron schedules that start runs. See Schedules.
authenticate(request) => Principal | nullMaps an HTTP API request to a caller. Without it, every /v1 route answers 401. Channel webhooks don't use it; they are verified with each provider's signing secret. See HTTP API.
ceilingPermissionsA permissions ceiling every run on this host is also decided under, on top of each agent's own rules. See Permissions & approvals.
In TypeScript ceiling takes a partial policy, for example { deny: ["bash"] }. In Python it is a complete Permissions value from threads.log.
Secrets
secret("NAME") is a reference to an environment variable, not its value. The host reads the value only when it needs it (to verify a webhook or send a reply), so tokens never end up in the log, a prompt or a sandbox. ready() fails with missing_secret if a channel's secret is not set, so you find out at startup, not on the first message.
Run it
The quickest way is the CLI, which loads your host module and serves it:
threads devIt prints each channel's webhook URL to paste into the provider's settings:
threads: dev listening on http://localhost:8787
slack: http://localhost:8787/channels/slack/eventsSee CLI for the module each language looks for, and Deploying the host for production.
Mount it in your own server
A host is also an ordinary request handler, so you can mount it next to your existing routes.
host().fetch takes a web Request and returns a Response, so it works in Bun, Next.js, Hono or anything fetch-based.
const server = Bun.serve({ port: 8787, fetch: app.fetch });
// Next.js App Router: app/api/[...threads]/route.ts
export const GET = (request: Request): Promise<Response> => app.fetch(request);
export const POST = (request: Request): Promise<Response> => app.fetch(request);Call await app.ready() once at startup and await app.stop() on shutdown. A host also supports await using, which calls stop() for you.
Lifecycle
| Call | What it does |
|---|---|
host({...}) | Builds the host. Starts nothing. |
ready() | Checks every channel and schedule points at a host agent and every secret resolves. Starts the scheduler and resumes work a restart left behind. Sends nothing by itself. |
stop() | Cancels runs, starts nothing new, then waits for work in flight and releases the threads this process owns. A reply already on its way keeps its claim on the thread until it finishes, so no other process sends it again while it may still arrive. A reply whose outcome stays unknown is checked with the channel later, or held for a person, never resent blindly. stop() has no timeout: put deadlines on your tools and providers. |
How channel conversations map to threads
Each conversation gets its own thread: a Slack channel or Slack thread, a WhatsApp sender, a GitHub issue or pull request. A new message continues that thread. If the agent is still busy, the message waits in a durable inbox and is picked up when the current run ends.
The agent's reply is sent back to the same conversation. Each reply is recorded before it is sent, so after a crash the host knows which replies may have gone out. It never resends one of those blindly: it checks with the provider, or holds the reply for a person, when it can't tell.
When a tool needs approval, the channel shows an approval card (buttons in Slack and WhatsApp, an /approve reply on GitHub). See Human-in-the-loop.