A self-hostable control plane for running coding agents that teams drive from chat.
One gateway owns the chat surfaces and every credential. Many runners own working trees and execute agents, holding nothing secret at rest.
Slack ─┐ ┌── Claude Code
Discord ├──▶ Surface adapters ──▶ GATEWAY ──▶ Runner ├── (your harness)
Web ──┘ (singleton) └── …
│
┌────┴────┐
│ SQLite │ routing · audit · usage
└─────────┘
See DESIGN.md for the architecture and the reasoning behind it.
A chat-to-agent bridge usually starts as one process: one chat app, one token, one channel allowlist, one machine, one working tree. Every axis is welded to every other, so adding an environment means adding all of them — and two processes sharing one chat app receive each message nondeterministically, which presents as "the bot is flaky" rather than as a configuration error.
Splitscreen separates the thing that talks to humans from the thing that runs agents:
- One chat app serves many environments, with distinct visible personas.
- Adding an environment is a config change, not an install procedure.
- No long-lived credentials on machines that execute agent code.
- Complete audit trail: every message, tool call, permission decision, file transfer, third-party API call, and token spent.
go install github.com/analystaio/splitscreen/cmd/splitscreen@latestOr build a static binary:
CGO_ENABLED=0 go build -o splitscreen ./cmd/splitscreenOne binary, one subcommand per role. Nothing else to install on a runner host.
1. Write a config. See examples/splitscreen.yaml.
gateway:
listen: 127.0.0.1:8443
secrets_dir: /etc/splitscreen/secrets
slack:
bot_token_secret: slack-bot
app_token_secret: slack-app
runners:
staging:
display: { name: "Ada", icon: ":robot_face:" }
cwd: /var/www/app
harness: claude-code
idle: 30m
routes:
- { channel: C0123456789, runner: staging }Validate it before anything else runs:
splitscreen config check -c splitscreen.yaml2. Generate a certificate and an enrollment token.
splitscreen cert --host 10.0.0.5 --cert gateway.crt --key gateway.key
splitscreen enroll staging --writecert prints a fingerprint for runners to pin. enroll --write mints a token,
stores the gateway's half in the configured secrets directory itself, and prints
only the runner's half — so the token is copied once rather than twice.
3. Run the gateway.
splitscreen gateway -c splitscreen.yaml4. Run a runner, on any host that can reach the gateway:
splitscreen runner \
--name staging \
--gateway wss://10.0.0.5:8443 \
--fingerprint sha256:… \
--token-file /etc/splitscreen/token \
--cwd /var/www/appRunners dial out. They never listen, so a private subnet, NAT, or a laptop all work with no inbound rules.
Send a message in a routed channel. Unrouted channels are ignored entirely — this replaces the per-runner allowlists a single-process bridge needs.
| Command | Effect |
|---|---|
!new |
Start a fresh session in this thread |
!rebind |
Move the thread to the channel's current runner |
!runner <name> |
Pick a runner when starting a new thread |
!status |
Runner roster, connection state, bundle versions, queue depths |
!routes |
The routing table |
!cost |
Spend and token usage for the last week |
!status and !routes also flag any routed channel the bot has not been
invited into. That case is worth calling out because the platform simply never
delivers those messages, making it indistinguishable from having no route at
all — silence with nothing to read. Checking it requires the channels:read
scope; without it the gateway reports "unverified" rather than guessing.
A read-only web view is served on loopback (127.0.0.1:8480 by default). Reach
it with a port-forward — aws ssm start-session, ssh -L, or equivalent — so
authorization stays the platform's problem rather than something this process
has to invent. Pass --web "" to disable it.
Threads are sticky: a thread keeps its runner even after a route changes, because its session lives on that runner's disk.
Everything credential-bearing lives on the gateway. The one exception is the harness credential, which must exist where the agent process runs.
| Credential | Where it lives |
|---|---|
| Chat platform tokens | Gateway only — runners never call a chat API |
| Git / forge | Gateway mints per operation, scoped to one repository |
| Credentialed MCP (Jira, Linear, …) | Gateway — proxied, so the runner never sees the token |
| Harness (Claude) | Gateway-held, materialized to the runner's tmpfs, never persisted |
Runners hold no forge credentials. Wire up the helper and the gateway answers per operation, after checking policy:
git config --global credential.helper '!splitscreen credential-helper'
git config --global credential.useHttpPath trueuseHttpPath matters: without it git does not tell the helper which repository
is being accessed, and per-repository scoping becomes impossible.
Layered, in order: a local directory wins over a cloud parameter, which wins over the environment. A local override for debugging is therefore obvious and temporary rather than something that silently shadows the real source.
gateway:
secrets_dir: /etc/splitscreen/secrets # one file per secret, 0600
secrets_ssm:
prefix: /splitscreen # AWS Parameter Store
region: us-east-2
cache_ttl: 5mFor automation, the cleanest enrollment is to skip enroll entirely: generate the
token wherever the runner will get it from, and write the gateway's copy to
<prefix>/runner-<name> in Parameter Store. The gateway reads it on the runner's next
connection; a reload that adds the runner drops any cached value, so there is no TTL to
wait out — and a failed authentication drops the cached value too, so a runner that
dialled before its parameter existed gets in on its next retry. Keep in mind that a file of the same name in secrets_dir wins —
runner add refuses to proceed while one exists. Where a script does want the local
store, enroll <name> --write --token-stdin stores a token it supplies, and
--print-token prints nothing but the token.
The Parameter Store backend reads with the host's own IAM identity, so there is no bootstrap secret on the gateway and every read is attributable in CloudTrail. Values are cached briefly because authentication resolves a secret on every runner connection — without it, a flapping runner would become an API storm.
Servers split by one question: does it need the runner's filesystem, or does it need a credential?
mcp:
fs: # needs the filesystem → runs on the runner
kind: local
command: /usr/bin/mcp-filesystem
jira: # needs a credential → runs through the gateway
kind: proxied
url: https://mcp.atlassian.com/v1/sse
auth: basic
user: bot@example.com
secret: jira-token
deny: ["deleteIssue"]A proxied server's deny list is enforced at the gateway, before the call
leaves it.
Skills (and agents, commands, helper scripts) can come from a Claude Code plugin marketplace kept in a git repository on the forge, pinned to a ref:
marketplaces:
acme:
repo: acme/claude-plugins
ref: v2026.10.01 # a tag or commit; moving it is a reviewable edit
bundles:
web:
plugins: [react-patterns@acme, browser-testing@acme]The runner fetches exactly that ref with a read-only credential the gateway
mints for the repository (any runner whose bundle uses the marketplace may read
it; its forge policy is otherwise unchanged, so it can never push there), and
each session loads the enabled plugins with --plugin-dir. Nothing is
installed into the harness's config: there is no plugin cache to drift and no
background update to move a pinned version. The ref is part of the bundle, so
bumping it marks live sessions stale like any other bundle change.
- Only relative-path plugin sources inside the marketplace load; a plugin that points at another repository would run code nobody pinned.
- By default plugins do not bring MCP servers: sessions run with
--strict-mcp-config, so only the bundle'smcp:servers load. A bundle can setstrict_mcp: false(inherited throughextends) to let plugins bring their own. The cost: a headless session then also loads the working tree's.mcp.jsonwithout asking, so any branch the agent checks out can start a server, outside the permission check. - The checkout lives in the runner's state dir when it has one, so a restart with the forge unreachable still loads the same ref. A different ref is never substituted; if a fetch fails, the session starts without the affected plugins and the failure is logged on the gateway.
Deny rules are evaluated before a permission prompt is posted, so a denied tool is never offered to a human and no click can approve it:
runners:
staging:
policy:
approvers: [U01ABC, U02DEF] # who may resolve prompts
deny:
- "Bash(git push --force*)"
- "Bash(terraform apply*)"
forge:
repos: ["acme/app"] # empty means no git credentials at allThat inversion is the core security property: file contents and third-party API responses are untrusted input the agent reads as instructions, so the agent's judgment cannot be the control.
Add and remove routes with the CLI rather than by hand; it validates before writing and edits the YAML node tree, so comments survive:
splitscreen route list
splitscreen route add C0123456789 staging
splitscreen route remove C0123456789
systemctl reload splitscreen-gatewayMessages from other bots are ignored unless the channel's route names the bot. That is how an alert relay or a CI bot hands work to a runner:
splitscreen route add C0123456789 ops --allow-bot B0123456789routes:
- { channel: C0123456789, runner: ops, allow_bots: [B0123456789] }On Slack an entry is the bot id (B…) or the bot's user id (U…). An allowed
bot follows the same rules as a person: it must mention the bot to start a
conversation, its message queues for an offline runner and wakes a wakeable
one, and the agent's context header marks it as coming from a bot. Bots never
reach a DM route, and an unlisted bot cannot continue a thread either, so two
bots cannot keep each other going.
Runners themselves are managed the same way. A control plane that creates a runner per task machine copies a template rather than writing YAML:
splitscreen runner list [--json]
splitscreen runner add box-foo --template box-template \
--set display.name="Box foo" --set host=i-0123456789abcdef0
splitscreen route add C0BOXFOO01 box-foo
systemctl reload splitscreen-gateway
splitscreen runner remove box-foo [--missing-ok] # also drops its routes
systemctl reload splitscreen-gateway--set takes a dotted path to a scalar field and creates missing sections; values are
typed as YAML would read them. token_secret is never copied from the template. A
template's wake block is copied without its ec2_instance; the copy wakes its own
host when that is an instance id (or whatever --set wake.ec2_instance says), so
--set host=i-… alone makes a wakeable runner. runner remove removes every route to the runner in the same edit and deletes
its file in secrets_dir; on reload the gateway disconnects it and refuses its token
from then on. Every edit takes a lock beside the config file, so concurrent invocations
cannot lose each other's changes, and keeps the file's owner when run as root.
There is deliberately no chat command that mutates routing. Which humans can
drive which machines and working trees is not a decision that should be typed by
whoever happens to be in the channel; !rebind exists for the thread-level case
because that has no blast radius.
While a runner has a turn in flight, Slack shows " is working…" in the
thread (assistant.threads.setStatus, which needs only chat:write). A message
waiting on a sleeping machine shows "is starting up…", one waiting on the
concurrency cap "is waiting for a free slot…". The gateway re-sets it through
Slack's two-minute expiry and clears it when the turn ends. Set the text with
working_status: "is thinking…" on a runner; working_status: "" turns it off.
A channel where Slack refuses it is logged once and then left alone.
Several people, and several channels, can share one runner. Every message therefore reaches the agent with a one-line header in front of it:
[Slack #box-foo (C0123ABCD) · from Jane Doe <jane@example.com> (U0456EFGH)]
It is on every turn, not just the first, because people take turns in a thread.
Names are resolved by the gateway and cached for an hour; the ids are always
there, so a missing scope shortens the header rather than dropping it. The
header is input to the agent only and is never posted back. Turn it off per
runner with context_header: false.
On Slack, names need users:read, emails users:read.email, and channel names
channels:read (public) or groups:read (private). Each one missing degrades
its part to the bare id; a failed lookup is retried after ten minutes.
A runner whose host stops itself when idle can name the machine to start:
runners:
box-foo:
wake:
ec2_instance: i-0123456789abcdef0
region: us-east-2 # default: gateway.secrets_ssm.region, then ambientWhen a message queues for it while it is offline, the gateway calls
ec2:StartInstances — at most once per runner per two minutes — and posts a visible
in-thread notice as the runner ("asleep — starting it now"), edited to "awake" when the
runner connects. A host still shutting down is retried for five minutes; a start that
fails says why in the thread and leaves the message queued. The gateway's IAM role needs
ec2:StartInstances on those instances; scope it with a tag condition.
Attachments sent to any offline runner are held and delivered when it reconnects, unless the gateway restarts first.
By default a runner's harness config lives on tmpfs and is rebuilt on every bundle push, so the agent's own memory, transcripts, and runtime-created skills do not survive a push or a reboot. Give the runner a state directory to keep them:
splitscreen runner --name box-foo ... --state-dir /home/ubuntu/.local/state/splitscreen/box-foo
# or SPLITSCREEN_STATE_DIR in the unit's environment fileprojects/ (memory and transcripts) and skills/ are then symlinked into it,
sessions.json keeps each thread's resume point across restarts, and CLAUDE.local.md
there is appended to the bundle's CLAUDE.md at every session start — the one durable
place for notes. Bundle skills still win on a name clash; bundle memory stays
bundle-owned.
splitscreen config check validates everything and reports every problem at
once. With --resolve, run as the gateway's user on the gateway host, it also
resolves every referenced secret through the configured backends, exactly as
startup does — use it to pre-flight a restart.
At startup a missing surface, forge, harness, or proxied-MCP secret is fatal. A
missing runner enrollment secret is not: that runner is logged as unenrolled and
refused until the secret exists, and everything else runs. One box's token — or
a template runner that is never enrolled at all — must not take every runner
down. A config either loads wholly or not at all — a bad edit never partially
applies, including on SIGHUP reload.
Validation distinguishes errors from warnings. An error means the config cannot work and blocks loading. A warning — a runner with no routes, say — means it probably is not what you meant, but it runs; blocking on those would make legitimate intermediate states unreachable, such as removing a runner's last route before removing the runner.
Runner fields: display (name, icon, show_activity), host, cwd, harness,
bundle, model, idle, max_concurrent, policy, token_secret, harness_secret,
harness_env, billing, context_header, working_status, and wake
(ec2_instance, region).
Bundle fields: extends, memory, skills, plugins (name@marketplace),
mcp, strict_mcp (default true). Marketplace fields: repo, ref (see Plugins).
See examples/splitscreen.yaml and the field comments in config/config.go.
Enforced invariants include: one channel maps to exactly one runner, at most one
DM route, bundle extends chains are acyclic, proxied MCP servers declare no
command, local ones declare no credential, and unknown keys are errors rather
than silently-ignored typos.
systemctl reload splitscreen-gateway # SIGHUP: re-read the config
journalctl -u splitscreen-gateway -f # structured JSON logsRunners run as an unprivileged user (harnesses refuse dangerous permission modes as root) with the runtime root on tmpfs, and optionally a persistent state directory (see above).
While any turn is in flight a runner keeps <runtime-root>/<name>/active (by default
$XDG_RUNTIME_DIR/splitscreen/<name>/active) with an mtime at most 30 s old, and
removes it when the last turn ends. A host that stops itself when idle can use it to
tell a long, quiet turn from no turn; treat an mtime older than about a minute as stale,
since a runner killed mid-turn cannot remove it. One host can serve several runners:
each gets its own config directory, its own unix socket, and its own persona.
systemctl --user enable --now splitscreen-runner@staging
systemctl --user enable --now splitscreen-runner@review # same host, second treeThe protocol, configuration, gateway, runner, Claude Code adapter, Slack surface, and read-only web view are implemented and tested.
Not yet built: per-user OAuth for third-party credentials (service-account tokens work today), signed cloud instance-identity authentication as an alternative to enrollment tokens, and additional surface and harness adapters — both plug points exist, but only one implementation of each ships.
ISC