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 8080

Or put it in the file.

substructure.toml
[serve]
host = "0.0.0.0"
port = 8080
auth = true

At startup the engine logs each declared agent and whether the engine or a worker decides for it.

What you hold

CredentialWhere it goes
Provider keysThe environment. api_key_env on each [llm.<id>] block names the variable.
Signing secretsThe environment. signing_secret_env on each [agent.<id>] names the variable.
Connector credentialsThe db file, written by subs mcp login.
Slack tokensThe 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.

substructure.toml
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.

substructure.toml
[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: false

agent_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.

substructure.toml
[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.

subs.prod.toml
[remote]
url = "https://engine.internal"
subs login -c subs.prod.toml
subs apply -c subs.prod.toml

The 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.

  1. Implement WorkspaceResolver. It maps a team to a bot token, a tenant, and an agent. Use one tenant per install, because slack:{channel}:{ts} ids are unique only within a workspace.
  2. Mount webhook_router. It verifies signatures on /events and /interactions, and answers url_verification.
  3. 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