Self-hosting
The engine's source is on GitHub. Run it yourself and you hold every credential.
A self-hosted engine serves the same APIs as the cloud. The same
substructure.toml describes both.
Run the server
subs serve --host 0.0.0.0 --port 8080Or put it in the file.
[serve]
host = "0.0.0.0"
port = 8080
auth = trueAt startup the engine logs each declared agent and whether the engine or a worker decides for it.
What you hold
| Credential | Where it goes |
|---|---|
| Provider keys | The environment. api_key_env on each [llm.<id>] block names the variable. |
| Signing secrets | The environment. signing_secret_env on each [agent.<id>] names the variable. |
| Connector credentials | The db file, written by subs mcp login. |
| Slack tokens | The environment. $SLACK_APP_TOKEN and $SLACK_BOT_TOKEN. |
| Client token secret | $CLIENT_TOKEN_HS256_SECRET. |
Storage
The engine writes to a store you can replace. The CLI uses SQLite at the path
db names.
db = "/var/lib/substructure/engine.db"The database holds the event log, the sessions, and the connector credentials. Back it up. See Durability.
Authentication
auth = true is the default. The engine then requires a client JWT on
/api/client and an API key on /api/machine.
Set $CLIENT_TOKEN_HS256_SECRET to the secret your backend signs client tokens
with. See Authentication.
--no-auth turns both off. Use it only for a server nothing off the machine can
reach.
Worker signing
Name the variable holding each agent's signing secret.
[agent.triage]
llm = "claude"
model = "claude-sonnet-4-5"
worker = "https://triage.internal/agent"
signing_secret_env = "TRIAGE_SIGNING_SECRET"An agent that names no variable gets unsigned requests. Set the same secret where the worker runs. See Workers.
Slack
A self-hosted engine talks to Slack over Socket Mode, with a Slack app you own. Socket Mode is an outbound WebSocket, so you need no public URL.
Create a Slack app with this manifest.
display_information:
name: substructure.ai
features:
bot_user:
display_name: substructure.ai
always_online: true
agent_view: {}
oauth_config:
scopes:
bot:
- im:history
- app_mentions:read
- channels:history
- chat:write
- assistant:write
pkce_enabled: false
settings:
event_subscriptions:
bot_events:
- app_mention
- message.im
interactivity:
is_enabled: true
org_deploy_enabled: false
socket_mode_enabled: true
token_rotation_enabled: false
is_mcp_enabled: falseagent_view turns on the Agents tab in app settings. That adds
assistant:write, which the bot needs to stream a turn's progress.
Then get the app token and the bot token, and run the server.
export SLACK_APP_TOKEN=xapp-...
export SLACK_BOT_TOKEN=xoxb-...
subs serve --slack-agent my-agent--slack-agent names the agent that answers DMs and any channel the file does
not name. Put the routing in the file instead.
[slack]
dm = "my-agent"
mentions = "my-agent"See Slack for the routing rules.
Administering a self-hosted deployment
[remote] names any server that speaks /api/v1. Point it at your own.
[remote]
url = "https://engine.internal"subs login -c subs.prod.toml
subs apply -c subs.prod.tomlThe CLI stores credentials per server, so you can be logged in to your deployment and the hosted cloud at once.
Embedding the engine
A Rust crate can embed the engine and drive it directly. A worker then becomes a callback instead of an HTTP endpoint.
The Slack bot's behavior lives in SlackBot, resolved for each workspace.
Socket Mode is a thin transport over it. To run the same bot over the Events API
instead, do three things.
- Implement
WorkspaceResolver. It maps a team to a bot token, a tenant, and an agent. Use one tenant per install, becauseslack:{channel}:{ts}ids are unique only within a workspace. - Mount
webhook_router. It verifies signatures on/eventsand/interactions, and answersurl_verification. - Call
SlackBot::start.
Both transports parse their deliveries into the same payloads. Run one
SlackBot per process, because the outbound processor keeps one named
checkpoint.
Next
- Config: every key the file holds.
- Authentication: callers, tokens, and identity.
- Durability: what the store holds.
- REST API: what the server exposes.