Services & capabilities
A service in Tabbify is just an app that talks to the outside world — a Telegram bot, an LLM agent, a webhook consumer. What makes it a service is the capability bundle it declares: the segments it may subscribe to and emit, the hosts it may call, and the secrets it needs. The supervisor enforces those bounds when it spawns the runner, so an app can never reach further than its manifest allows.
Declare a capability bundle
Capabilities live in the manifest you ship with the app. A bundle is a closed contract: the supervisor reads it at spawn and grants exactly what is listed, nothing more.
[agent]
name = "my-agent"
[runtime]
image = "tabbify/my-agent:latest"
language = "rust"
[capabilities]
subscribe_segments = ["tenant_*.chat", "platform.llm"]
emit_segments = ["tenant_*.code"]
http_allow_list = []
[[secrets]]
name = "anthropic_api_key"
env_var = "ANTHROPIC_API_KEY"
required = true
subscribe_segments and emit_segments use glob matching on subscribe (tenant_*.code); emits must be a literal segment. http_allow_list bounds outbound HTTP — an empty list means the app makes no external calls. Every [[secrets]] entry names a secret and the env var it gets injected into.
Wrap an external service
To wrap a third-party API, you write a small headless app that consumes inbound events, calls the API, and emits the result back to the log. External integrations register through the api-gateway, which mints a webhook URL:
curl -X POST http://localhost:8000/integrations/telegram/register \
-H 'Content-Type: application/json' \
-d '{"bot_token": "<token>", "webhook_secret": "<secret>"}'
# -> { "integration_id": "...", "webhook_url": "http://localhost:8000/wh/telegram/<integration_id>" }
Inbound traffic hits /wh/telegram/:integration_id (or /wh/generic/:integration_id for anything else), the gateway validates the secret header, and emits a TelegramMessageReceived or ExternalWebhookReceived event. Your service subscribes to that segment and does the work. Nothing talks to your app directly — see Core concepts for why everything routes through events.
Secrets stay out of the prompt
Plaintext secrets are never placed in event payloads. They live in an encrypted store (XChaCha20-Poly1305) and are resolved out of band at spawn time. Register one with the CLI:
echo "sk-ant-1234567890" | tabbify secrets register anthropic_api_key api-key --from-stdin --tenant default
tabbify secrets list # metadata only — never prints plaintext
When the supervisor spawns a runner, it resolves each declared secret by calling the gateway over HTTP and injects the decrypted bytes as environment variables — the listed env_var, never the model context. There is a hard reason for the HTTP hop: the secret store is a single-writer redb database, so the supervisor cannot open it directly without deadlocking the api-gateway. It uses the HttpSecretsResolver against POST /secrets/resolve instead.
POST /secrets/resolve (supervisor -> api-gateway, on spawn only)
The model asks for an action; the runtime performs it with the credential; the model only sees the result. Audit events capture that a secret was accessed, separately from the log — the value itself never appears.
For the manifest fields and runtime types, see The tabbify.toml manifest and Runtimes. For how requests reach a running service, see Routing & public access.