An AI personal assistant with a focus on security.
See the code
Stavrobot is a personal AI assistant built with the principle of "all the access an AI assistant needs, and no more".
It has all the nice features of an AI assistant, but focuses on sandboxing, isolation, and minimal permissions. It's lightweight and is deployed with only a docker compose up.
uv, isolated from the host environment.AI-assisted install: Tell your coding AI (Claude Code, Cursor, Windsurf, etc.) to fetch and follow https://stavrobot.stavros.io/INSTALL.md — it will walk you through the whole setup interactively.
Manual install:
Message @BotFather on Telegram to create a bot and copy the token. Message @userinfobot to get your Telegram user ID (this is used as the chat ID).
Copy env.example to .env and change POSTGRES_PASSWORD to something secure and TZ to your timezone.
Copy config.example.toml to data/main/config.toml and fill in the required fields: apiKey, password, publicHostname, [owner].name, [owner].telegram (your chat ID), and [telegram].botToken. Everything else in the file is optional.
docker compose up --build
That's it. Message your bot on Telegram and it will respond. See the detailed setup sections below for Signal, WhatsApp, email, and other options.
config.example.toml to data/main/config.toml.authFile (or apiKey) and publicHostname. Everything else is optional.env.example to .env and set your timezone (TZ). Postgres credentials and other environment settings can also be overridden there. Always set POSTGRES_PASSWORD to something secure — the default is a weak placeholder and should not be used in production.After the app starts, open /settings/config to edit config.toml in the browser. The
page is protected by HTTP Basic Auth and displays the raw configuration, including any
secrets it contains. A valid save creates a single <CONFIG_PATH>.bak backup, saves the
new content, and restarts the app; this interrupts any in-progress agent turn.
plugin-runner, coder, signal-bridge, and python-runner read config.toml only
when they start. Changes that affect them, such as the password, require manually
restarting those containers.
Stavrobot can be pointed at any OpenAI-compatible endpoint (Ollama, LiteLLM, vLLM, etc.) or a custom Anthropic-compatible proxy by setting baseUrl in config.toml. See config.example.toml for the required fields and example configurations.
The app supports two authentication modes: API key or OAuth.
apiKey in config.toml. No login or logout needed.authFile in config.toml (a path where credentials will be stored). The login page works with any OAuth provider supported by Pi.
<your-hostname>/login in a browser. Follow the prompts on the page, and credentials are saved to the auth file. If auth expires while the bot is running, it sends a message with the login URL to you via your messaging platform.authFile path. The bot will detect missing credentials on the next message and prompt you to log in again.The coder container is optional (needed only for the self-programming feature). It uses Claude Code with subscription auth (OAuth), separate from the main app's API key.
Docker Compose profiles are comma-separated, so you can combine them (e.g. COMPOSE_PROFILES=signal,coder).
COMPOSE_PROFILES in your .env file to include coder (e.g. COMPOSE_PROFILES=coder, or COMPOSE_PROFILES=signal,coder if you are also using Signal).docker compose --profile coder up --builddocker compose exec -u coder coder claude (it will prompt you to log in if you haven't).[coder].model in your config to a Claude Code model alias (sonnet, opus, or haiku).Signal requires a separate phone number — not your personal one. A prepaid SIM or VoIP number works.
COMPOSE_PROFILES=signal in your .env file to enable the signal-bridge container.docker compose --profile signal builddocker compose --profile signal run --rm --entrypoint bash signal-bridge -c 'signal-cli link -n "Stavrobot" | tee >(xargs -L 1 qrencode -t utf8)' — scan the QR code with your phone (Signal > Settings > Linked devices).docker compose --profile signal run --rm --entrypoint bash signal-bridge -c 'signal-cli -u +YOUR_NUMBER register', then verify with docker compose --profile signal run --rm --entrypoint bash signal-bridge -c 'signal-cli -u +YOUR_NUMBER verify CODE'.[signal].account in your config.docker compose up --build/settings web UI.docker compose exec app node -e "fetch('http://localhost:3001/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:'Send the message \"Hello from Stavrobot\" to my Signal number.'})}).then(r=>r.text()).then(console.log)"
[telegram].botToken in your config.[owner].telegram in your config./settings web UI.WhatsApp requires a separate phone number, as otherwise you'd be messaging yourself, which doesn't really work.
WhatsApp uses Baileys, an unofficial WhatsApp Web library that links as a companion device (like WhatsApp Web). No separate phone number is needed — it links to your existing WhatsApp account.
Risk: Baileys uses an unofficial API. WhatsApp may ban accounts that use it. Use at your own risk.
[whatsapp] section to your config.toml (see config.example.toml for the format).docker compose up --builddocker compose logs -f app)../data/whatsapp./settings web UI.Email uses a Cloudflare Email Worker for inbound delivery and SMTP for outbound. See
config.example.toml for the complete worker code and detailed setup instructions.
[email] section to your config.toml with SMTP credentials and a random webhookSecret.config.example.toml) and set the WEBHOOK_URL and WEBHOOK_SECRET environment variables on the worker./settings web UI.Configure Pebble Index to send ring webhooks to POST /pebble-index/webhook with HTTP
Basic authentication using Stavrobot's configured password. The multipart request must
include recordedAt (epoch milliseconds) and client, and can include a transcription
and an audio/mp4 audio file. Audio recordings are queued for the agent as .m4a
attachments.
docker compose up --build
The API is available at http://localhost:10567/chat. See HTTP API for the
full list of endpoints.
Note: Docker Compose only exposes the app on localhost:10567. To make it accessible externally (required for Telegram/Signal webhooks and the publicHostname setting), set up a reverse proxy (e.g. Nginx, Caddy) pointing to localhost:10567. You can also expose the port directly, but this is not recommended as traffic will be unencrypted.
Requires Node.js >= 20 and a running PostgreSQL instance.
npm install && npm run build && npm start
Note: Python execution and Signal integration only work inside the Docker containers.
Every endpoint requires HTTP Basic authentication with the password from config.toml
(the username is ignored), except the ones marked public below. Error responses are
JSON objects of the form {"error": "..."}.
POST /chatSends a message to the agent and returns its reply. This is the main entry point: the web UI, the Signal bridge, the plugin runner, the coder, and the cron scheduler all use it.
Messages are processed one at a time through a single queue. By default, the request
blocks until the agent has finished its turn, which can take a while if the agent uses
tools. Set "async": true to acknowledge after the message is submitted through
enqueueMessage without waiting for the turn to finish.
Request body is a JSON object (maximum 25 MB) with these fields. At least one of
message, files, or attachments is required.
| Field | Type | Description |
|---|---|---|
message | string | The text to send to the agent. |
async | boolean | Optional. Set to the literal true to receive 202 with {"accepted":true} after submission instead of waiting for the reply. Omit it or set it to false to wait normally. Other values are rejected. |
source | string | The channel the message came from. Controls routing (see below) and is shown to the agent alongside the message. Omit it for direct calls; the agent then sees cli. |
sender | string | Who sent the message within that channel: an E.164 phone number for signal and whatsapp, a chat ID for telegram, an email address for email. Shown to the agent. |
files | array | Files sent inline. Each entry is { "data": "<base64>", "filename": "...", "mimeType": "..." }. Files larger than 10 MB after decoding are skipped with a warning in the logs. Use this from outside the app container. |
attachments | array | Files already present in the app container's temporary upload directory. Each entry is { "storedPath", "originalFilename", "mimeType", "size" }. Paths outside the upload directory are rejected. This is for internal callers only; external callers should use files. |
Routing by source:
cli, cron, coder, upload, or plugin:<name>: goes to the
main agent as a message from you.signal, telegram, whatsapp, email: sender is required. If it matches one of
your identities in [owner], the message goes to the main agent. Otherwise the sender
must be in the allowlist and be assigned to an agent as an interlocutor; if not, the
message is dropped.Two special cases:
/stop, the running agent turn is aborted. Nothing is queued.
A synchronous request receives Aborted..Message received, steering the current request. The agent's
reply becomes part of the running turn and is returned only when the HTTP request that
started that turn is synchronous.Response: with async omitted or set to false, 200 with
{"response": "<agent reply>"}. With async: true, the response is 202 with
{"accepted":true}; it follows the same enqueue path, preserving routing, /stop, and
owner steering behavior, but no agent reply is returned to that request. For synchronous
calls, a dropped message (unknown sender on a gated channel) also returns 200, with an
empty response string.
The acknowledgement confirms submission to the in-memory queue, not eventual completion. Queued work is lost if the app restarts before processing it, and any later enqueue rejection is logged because the HTTP response has already been sent.
Errors: 400 for invalid JSON, a body with none of the required fields, or a present
non-boolean async field; 401 for a missing or wrong password, 413 if the body
exceeds 25 MB, 500 for anything else.
Examples:
curl -u :yourpassword -H 'Content-Type: application/json' \
-d '{"message": "What is on my calendar today?"}' \
http://localhost:10567/chat
curl -u :yourpassword -H 'Content-Type: application/json' \
-d '{"message": "Process this in the background.", "async": true}' \
http://localhost:10567/chat
curl -u :yourpassword -H 'Content-Type: application/json' \
-d "{\"message\": \"Summarize this.\", \"files\": [{\"data\": \"$(base64 -w0 notes.txt)\", \"filename\": \"notes.txt\", \"mimeType\": \"text/plain\"}]}" \
http://localhost:10567/chat
POST /api/uploadUploads a single file and hands it to the agent as a message with no text and
source: upload. Returns immediately; the agent processes the file in the background.
Body is multipart/form-data with a file part (maximum 10 MB) and an optional
filename text field that overrides the original filename.
Response: 200 with {"message": "File uploaded successfully", "filename": "<stored name>"}.
Errors: 400 if the file part is missing, 413 if the file exceeds 10 MB.
These accept inbound messages from external services. Each has its own authentication.
POST /telegram/webhook (public). Called by Telegram. Authenticated by the
X-Telegram-Bot-Api-Secret-Token header, which Stavrobot generates and registers with
Telegram at startup. Returns 403 on a bad secret, 404 if Telegram is not configured.
Responds 200 {"ok": true} immediately and processes the update in the background.POST /email/webhook (public). Called by the Cloudflare Email Worker. Authenticated
by Authorization: Bearer <webhookSecret> from the [email] config section. Body is
JSON { "from", "to", "raw" } where raw is the full RFC 822 message. Responds
200 {"ok": true} immediately and processes the email in the background. Returns 404
if email is not configured.POST /pebble-index/webhook. Basic auth. Body is multipart/form-data with
recordedAt (epoch milliseconds), client, and at least one of a transcription text
field or an audio file part (audio/mp4, maximum 25 MB). Responds 200 {"ok": true}
immediately and queues the recording for the agent in the background.Pages are files the agent publishes through its manage_pages tool. Each page is either
public or private.
GET /pages/<path> (public route, per-page auth). Serves the latest version of the
page with its stored MIME type. Private pages require Basic auth. Deleted or unknown
pages return 404.GET /api/pages/<path>/queries/<name> (public route, per-page auth). Runs a named
read-only SQL query stored with the page and returns the rows as a JSON array. Query
placeholders of the form $param:foo are filled from the ?foo= query string; a
missing parameter returns 400. Auth follows the page's public flag.GET /api/explorer/tables: JSON list of tables.GET /api/explorer/tables/<table>: JSON schema of one table. 404 if it does not exist.GET /api/explorer/tables/<table>/rows: JSON rows. Query parameters: limit (1 to
100, default 50), offset (default 0), orderBy (column name), orderDirection
(asc or desc, default asc).These proxy to the plugin runner. Bodies are JSON.
GET /api/settings/plugins/list: all installed plugins.GET /api/settings/plugins/<name>/detail: one plugin's manifest.GET /api/settings/plugins/<name>/config: the plugin's configuration, including
secret values. Never exposed to the agent.POST /api/settings/plugins/install: body { "url": "<git url>" }.POST /api/settings/plugins/update: body { "name": "<plugin>" }.POST /api/settings/plugins/remove: body { "name": "<plugin>" }.POST /api/settings/plugins/configure: body { "name": "<plugin>", "config": { ... } }.
Replaces the plugin's configuration.GET /api/settings/allowlist: returns { "allowlist", "ownerIdentities" }. The
allowlist has signal, whatsapp, email (arrays of strings), telegram (array of
integers), and notes (object mapping an entry to a free-text label).PUT /api/settings/allowlist: body is the same allowlist shape. All four arrays are
required; notes is optional. Phone numbers must be E.164, Telegram entries must be
integers, and "*" in any list allows everyone on that channel. Your own identities from
[owner] are always added back if omitted. Returns the saved allowlist.GET /login/events: server-sent events stream that drives the OAuth login flow started
from the /login page. Event types: auth, device_code, prompt, progress,
success, error_event.POST /login/respond: body { "value": "<code>" }, answers the pending prompt from
the login flow. 409 if no prompt is pending.POST /signal/captcha: body { "captcha": "signalcaptcha://..." }, forwarded to the
Signal bridge when Signal registration requires a captcha.GET / (chat), /login, /explorer, /settings, /settings/plugins,
/settings/allowlist, and /signal/captcha serve the HTML pages that use the endpoints
above. /plugins redirects to /settings/plugins.
Stavrobot has a three-tier knowledge system: memories, a scratchpad, and the database. It will manage these fairly well on its own, but they're important for you to know because you will want to help the bot use them properly.
Memories are always injected into the system prompt wholesale. You should be frugal with these, as they get included in the context every time, and having many of them can increase the context. An example of a good memory would be "the user prefers chatting in an informal style.", something that the bot should know about every time, even if that costs in additional context length.
The scratchpad is where the bot keeps less frequently-accessed knowledge, but knowledge that pertains to a topic. Scratchpad entries have a title and a body, and the title gets injected into the context automatically. Use this for important, but less-frequently needed things, things that usually pertain to a specific topic.
For example, a good scratchpad entry would be "dietary preferences", with details about what you like to eat, when, etc. The bot will see that there's a topic "dietary preferences", but not load the actual text itself into the context automatically, only accessing it on-demand.
When you're talking to the bot about food, it will know there's a "dietary preferences" scratchpad entry it can look at, and usually do that on its own.
The database is the third tier, for structured or bulk data that doesn't belong in memories or the scratchpad. The bot has full read/write access to PostgreSQL via unrestricted SQL, so it can create tables, run queries, and store anything. Use this for things like lists, logs, structured records, or any data that's better queried than read as prose.
The bot will usually know well enough what to use when, but sometimes you will want to tell it explicitly what information to put where.
Stavrobot can message people on your behalf over Signal, Telegram, WhatsApp, or email. Need to schedule a dinner with a friend? Tell the bot to find a time that works for both of you, and it will message them, negotiate a date, and put it on your calendar. Want to arrange an appointment, coordinate a group outing, or ask someone a question while you're busy? Just tell the bot what you need and who to talk to.
The bot spins up a dedicated subagent for each conversation, with its own instructions and context, so it can handle back-and-forth with the other person without cluttering your main chat. When the task is done, it disables the contact and reports back to you.
To keep things safe, messaging requires two things before the bot can talk to someone:
/settings and add their phone number
(Signal or WhatsApp) or chat ID (Telegram). This is a one-time step per person. The
bot cannot modify this list or message anyone not on it, no matter what.A typical flow: you add your friend's phone number to the allowlist via /settings once,
then tell the bot "find a time for dinner with Alex next week". The bot creates a contact
record for Alex, spins up a subagent, messages Alex on Signal, goes back and forth to
find a date, and reports the result to you.
Skills are plain-text instruction files that teach the bot new capabilities — things like creating tables, storing knowledge, making pages, or following specific workflows. They are not code; they are natural-language instructions the bot reads and follows.
To install the skill system, tell the bot to fetch
https://stavrobot.stavros.io/skills/bootstrap.md and follow its instructions.
A full list of available skills is at
https://stavrobot.stavros.io/skills/index.md. To install a skill, just tell the bot to
fetch and install it from that URL. The bot will show you what the skill does and ask for
confirmation before proceeding.
Skill files live on the pages branch of this repo (served via Cloudflare Pages). To
contribute a skill, add a markdown file to skills/ on that branch. Each skill file has
YAML front matter with title, description, version, and an optional author,
followed by the plain-language instructions in the body. See
SKILL-AUTHORING.md for the full authoring guide. Submit a PR
against the pages branch.
See PLUGIN.md for everything you need to know to create a Stavrobot plugin. The same document works as a reference for both humans and LLMs.
Plugins are fairly necessary to enable core functionality for the bot, such as the ability to "hear" voice notes, send voice notes back, be aware of places around you, search the web, etc.
These capabilities are not provided out-of-the-box to give you the ability to customize your bot however you want. You should install plugins, though, otherwise you will be missing crucial functionality.
You can find some first-party plugins here:
https://github.com/orgs/stavrobot/repositories
Three core Docker containers: app (TypeScript server, exposes POST /chat, handles Telegram webhooks at POST /telegram/webhook, handles inbound email webhooks at POST /email/webhook, and runs WhatsApp in-process via Baileys), postgres (PostgreSQL 17 for persistent state), and plugin-runner (Node.js server — lists, inspects, and executes plugins, both locally created and git-installed). An optional coder container (Claude Code headless agent for creating and modifying editable plugins) is enabled via the coder Docker Compose profile. The main agent can create subagents, each with their own conversation history, system prompt, and tool whitelist. Interlocutors are contact records assigned to agents for inbound message routing.
341 commits
TypeScript
92.1%
Python
7.2%
An AI personal assistant with a focus on security.
See the code
Stavrobot is a personal AI assistant built with the principle of "all the access an AI assistant needs, and no more".
It has all the nice features of an AI assistant, but focuses on sandboxing, isolation, and minimal permissions. It's lightweight and is deployed with only a docker compose up.
uv, isolated from the host environment.AI-assisted install: Tell your coding AI (Claude Code, Cursor, Windsurf, etc.) to fetch and follow https://stavrobot.stavros.io/INSTALL.md — it will walk you through the whole setup interactively.
Manual install:
Message @BotFather on Telegram to create a bot and copy the token. Message @userinfobot to get your Telegram user ID (this is used as the chat ID).
Copy env.example to .env and change POSTGRES_PASSWORD to something secure and TZ to your timezone.
Copy config.example.toml to data/main/config.toml and fill in the required fields: apiKey, password, publicHostname, [owner].name, [owner].telegram (your chat ID), and [telegram].botToken. Everything else in the file is optional.
docker compose up --build
That's it. Message your bot on Telegram and it will respond. See the detailed setup sections below for Signal, WhatsApp, email, and other options.
config.example.toml to data/main/config.toml.authFile (or apiKey) and publicHostname. Everything else is optional.env.example to .env and set your timezone (TZ). Postgres credentials and other environment settings can also be overridden there. Always set POSTGRES_PASSWORD to something secure — the default is a weak placeholder and should not be used in production.After the app starts, open /settings/config to edit config.toml in the browser. The
page is protected by HTTP Basic Auth and displays the raw configuration, including any
secrets it contains. A valid save creates a single <CONFIG_PATH>.bak backup, saves the
new content, and restarts the app; this interrupts any in-progress agent turn.
plugin-runner, coder, signal-bridge, and python-runner read config.toml only
when they start. Changes that affect them, such as the password, require manually
restarting those containers.
Stavrobot can be pointed at any OpenAI-compatible endpoint (Ollama, LiteLLM, vLLM, etc.) or a custom Anthropic-compatible proxy by setting baseUrl in config.toml. See config.example.toml for the required fields and example configurations.
The app supports two authentication modes: API key or OAuth.
apiKey in config.toml. No login or logout needed.authFile in config.toml (a path where credentials will be stored). The login page works with any OAuth provider supported by Pi.
<your-hostname>/login in a browser. Follow the prompts on the page, and credentials are saved to the auth file. If auth expires while the bot is running, it sends a message with the login URL to you via your messaging platform.authFile path. The bot will detect missing credentials on the next message and prompt you to log in again.The coder container is optional (needed only for the self-programming feature). It uses Claude Code with subscription auth (OAuth), separate from the main app's API key.
Docker Compose profiles are comma-separated, so you can combine them (e.g. COMPOSE_PROFILES=signal,coder).
COMPOSE_PROFILES in your .env file to include coder (e.g. COMPOSE_PROFILES=coder, or COMPOSE_PROFILES=signal,coder if you are also using Signal).docker compose --profile coder up --builddocker compose exec -u coder coder claude (it will prompt you to log in if you haven't).[coder].model in your config to a Claude Code model alias (sonnet, opus, or haiku).Signal requires a separate phone number — not your personal one. A prepaid SIM or VoIP number works.
COMPOSE_PROFILES=signal in your .env file to enable the signal-bridge container.docker compose --profile signal builddocker compose --profile signal run --rm --entrypoint bash signal-bridge -c 'signal-cli link -n "Stavrobot" | tee >(xargs -L 1 qrencode -t utf8)' — scan the QR code with your phone (Signal > Settings > Linked devices).docker compose --profile signal run --rm --entrypoint bash signal-bridge -c 'signal-cli -u +YOUR_NUMBER register', then verify with docker compose --profile signal run --rm --entrypoint bash signal-bridge -c 'signal-cli -u +YOUR_NUMBER verify CODE'.[signal].account in your config.docker compose up --build/settings web UI.docker compose exec app node -e "fetch('http://localhost:3001/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:'Send the message \"Hello from Stavrobot\" to my Signal number.'})}).then(r=>r.text()).then(console.log)"
[telegram].botToken in your config.[owner].telegram in your config./settings web UI.WhatsApp requires a separate phone number, as otherwise you'd be messaging yourself, which doesn't really work.
WhatsApp uses Baileys, an unofficial WhatsApp Web library that links as a companion device (like WhatsApp Web). No separate phone number is needed — it links to your existing WhatsApp account.
Risk: Baileys uses an unofficial API. WhatsApp may ban accounts that use it. Use at your own risk.
[whatsapp] section to your config.toml (see config.example.toml for the format).docker compose up --builddocker compose logs -f app)../data/whatsapp./settings web UI.Email uses a Cloudflare Email Worker for inbound delivery and SMTP for outbound. See
config.example.toml for the complete worker code and detailed setup instructions.
[email] section to your config.toml with SMTP credentials and a random webhookSecret.config.example.toml) and set the WEBHOOK_URL and WEBHOOK_SECRET environment variables on the worker./settings web UI.Configure Pebble Index to send ring webhooks to POST /pebble-index/webhook with HTTP
Basic authentication using Stavrobot's configured password. The multipart request must
include recordedAt (epoch milliseconds) and client, and can include a transcription
and an audio/mp4 audio file. Audio recordings are queued for the agent as .m4a
attachments.
docker compose up --build
The API is available at http://localhost:10567/chat. See HTTP API for the
full list of endpoints.
Note: Docker Compose only exposes the app on localhost:10567. To make it accessible externally (required for Telegram/Signal webhooks and the publicHostname setting), set up a reverse proxy (e.g. Nginx, Caddy) pointing to localhost:10567. You can also expose the port directly, but this is not recommended as traffic will be unencrypted.
Requires Node.js >= 20 and a running PostgreSQL instance.
npm install && npm run build && npm start
Note: Python execution and Signal integration only work inside the Docker containers.
Every endpoint requires HTTP Basic authentication with the password from config.toml
(the username is ignored), except the ones marked public below. Error responses are
JSON objects of the form {"error": "..."}.
POST /chatSends a message to the agent and returns its reply. This is the main entry point: the web UI, the Signal bridge, the plugin runner, the coder, and the cron scheduler all use it.
Messages are processed one at a time through a single queue. By default, the request
blocks until the agent has finished its turn, which can take a while if the agent uses
tools. Set "async": true to acknowledge after the message is submitted through
enqueueMessage without waiting for the turn to finish.
Request body is a JSON object (maximum 25 MB) with these fields. At least one of
message, files, or attachments is required.
| Field | Type | Description |
|---|---|---|
message | string | The text to send to the agent. |
async | boolean | Optional. Set to the literal true to receive 202 with {"accepted":true} after submission instead of waiting for the reply. Omit it or set it to false to wait normally. Other values are rejected. |
source | string | The channel the message came from. Controls routing (see below) and is shown to the agent alongside the message. Omit it for direct calls; the agent then sees cli. |
sender | string | Who sent the message within that channel: an E.164 phone number for signal and whatsapp, a chat ID for telegram, an email address for email. Shown to the agent. |
files | array | Files sent inline. Each entry is { "data": "<base64>", "filename": "...", "mimeType": "..." }. Files larger than 10 MB after decoding are skipped with a warning in the logs. Use this from outside the app container. |
attachments | array | Files already present in the app container's temporary upload directory. Each entry is { "storedPath", "originalFilename", "mimeType", "size" }. Paths outside the upload directory are rejected. This is for internal callers only; external callers should use files. |
Routing by source:
cli, cron, coder, upload, or plugin:<name>: goes to the
main agent as a message from you.signal, telegram, whatsapp, email: sender is required. If it matches one of
your identities in [owner], the message goes to the main agent. Otherwise the sender
must be in the allowlist and be assigned to an agent as an interlocutor; if not, the
message is dropped.Two special cases:
/stop, the running agent turn is aborted. Nothing is queued.
A synchronous request receives Aborted..Message received, steering the current request. The agent's
reply becomes part of the running turn and is returned only when the HTTP request that
started that turn is synchronous.Response: with async omitted or set to false, 200 with
{"response": "<agent reply>"}. With async: true, the response is 202 with
{"accepted":true}; it follows the same enqueue path, preserving routing, /stop, and
owner steering behavior, but no agent reply is returned to that request. For synchronous
calls, a dropped message (unknown sender on a gated channel) also returns 200, with an
empty response string.
The acknowledgement confirms submission to the in-memory queue, not eventual completion. Queued work is lost if the app restarts before processing it, and any later enqueue rejection is logged because the HTTP response has already been sent.
Errors: 400 for invalid JSON, a body with none of the required fields, or a present
non-boolean async field; 401 for a missing or wrong password, 413 if the body
exceeds 25 MB, 500 for anything else.
Examples:
curl -u :yourpassword -H 'Content-Type: application/json' \
-d '{"message": "What is on my calendar today?"}' \
http://localhost:10567/chat
curl -u :yourpassword -H 'Content-Type: application/json' \
-d '{"message": "Process this in the background.", "async": true}' \
http://localhost:10567/chat
curl -u :yourpassword -H 'Content-Type: application/json' \
-d "{\"message\": \"Summarize this.\", \"files\": [{\"data\": \"$(base64 -w0 notes.txt)\", \"filename\": \"notes.txt\", \"mimeType\": \"text/plain\"}]}" \
http://localhost:10567/chat
POST /api/uploadUploads a single file and hands it to the agent as a message with no text and
source: upload. Returns immediately; the agent processes the file in the background.
Body is multipart/form-data with a file part (maximum 10 MB) and an optional
filename text field that overrides the original filename.
Response: 200 with {"message": "File uploaded successfully", "filename": "<stored name>"}.
Errors: 400 if the file part is missing, 413 if the file exceeds 10 MB.
These accept inbound messages from external services. Each has its own authentication.
POST /telegram/webhook (public). Called by Telegram. Authenticated by the
X-Telegram-Bot-Api-Secret-Token header, which Stavrobot generates and registers with
Telegram at startup. Returns 403 on a bad secret, 404 if Telegram is not configured.
Responds 200 {"ok": true} immediately and processes the update in the background.POST /email/webhook (public). Called by the Cloudflare Email Worker. Authenticated
by Authorization: Bearer <webhookSecret> from the [email] config section. Body is
JSON { "from", "to", "raw" } where raw is the full RFC 822 message. Responds
200 {"ok": true} immediately and processes the email in the background. Returns 404
if email is not configured.POST /pebble-index/webhook. Basic auth. Body is multipart/form-data with
recordedAt (epoch milliseconds), client, and at least one of a transcription text
field or an audio file part (audio/mp4, maximum 25 MB). Responds 200 {"ok": true}
immediately and queues the recording for the agent in the background.Pages are files the agent publishes through its manage_pages tool. Each page is either
public or private.
GET /pages/<path> (public route, per-page auth). Serves the latest version of the
page with its stored MIME type. Private pages require Basic auth. Deleted or unknown
pages return 404.GET /api/pages/<path>/queries/<name> (public route, per-page auth). Runs a named
read-only SQL query stored with the page and returns the rows as a JSON array. Query
placeholders of the form $param:foo are filled from the ?foo= query string; a
missing parameter returns 400. Auth follows the page's public flag.GET /api/explorer/tables: JSON list of tables.GET /api/explorer/tables/<table>: JSON schema of one table. 404 if it does not exist.GET /api/explorer/tables/<table>/rows: JSON rows. Query parameters: limit (1 to
100, default 50), offset (default 0), orderBy (column name), orderDirection
(asc or desc, default asc).These proxy to the plugin runner. Bodies are JSON.
GET /api/settings/plugins/list: all installed plugins.GET /api/settings/plugins/<name>/detail: one plugin's manifest.GET /api/settings/plugins/<name>/config: the plugin's configuration, including
secret values. Never exposed to the agent.POST /api/settings/plugins/install: body { "url": "<git url>" }.POST /api/settings/plugins/update: body { "name": "<plugin>" }.POST /api/settings/plugins/remove: body { "name": "<plugin>" }.POST /api/settings/plugins/configure: body { "name": "<plugin>", "config": { ... } }.
Replaces the plugin's configuration.GET /api/settings/allowlist: returns { "allowlist", "ownerIdentities" }. The
allowlist has signal, whatsapp, email (arrays of strings), telegram (array of
integers), and notes (object mapping an entry to a free-text label).PUT /api/settings/allowlist: body is the same allowlist shape. All four arrays are
required; notes is optional. Phone numbers must be E.164, Telegram entries must be
integers, and "*" in any list allows everyone on that channel. Your own identities from
[owner] are always added back if omitted. Returns the saved allowlist.GET /login/events: server-sent events stream that drives the OAuth login flow started
from the /login page. Event types: auth, device_code, prompt, progress,
success, error_event.POST /login/respond: body { "value": "<code>" }, answers the pending prompt from
the login flow. 409 if no prompt is pending.POST /signal/captcha: body { "captcha": "signalcaptcha://..." }, forwarded to the
Signal bridge when Signal registration requires a captcha.GET / (chat), /login, /explorer, /settings, /settings/plugins,
/settings/allowlist, and /signal/captcha serve the HTML pages that use the endpoints
above. /plugins redirects to /settings/plugins.
Stavrobot has a three-tier knowledge system: memories, a scratchpad, and the database. It will manage these fairly well on its own, but they're important for you to know because you will want to help the bot use them properly.
Memories are always injected into the system prompt wholesale. You should be frugal with these, as they get included in the context every time, and having many of them can increase the context. An example of a good memory would be "the user prefers chatting in an informal style.", something that the bot should know about every time, even if that costs in additional context length.
The scratchpad is where the bot keeps less frequently-accessed knowledge, but knowledge that pertains to a topic. Scratchpad entries have a title and a body, and the title gets injected into the context automatically. Use this for important, but less-frequently needed things, things that usually pertain to a specific topic.
For example, a good scratchpad entry would be "dietary preferences", with details about what you like to eat, when, etc. The bot will see that there's a topic "dietary preferences", but not load the actual text itself into the context automatically, only accessing it on-demand.
When you're talking to the bot about food, it will know there's a "dietary preferences" scratchpad entry it can look at, and usually do that on its own.
The database is the third tier, for structured or bulk data that doesn't belong in memories or the scratchpad. The bot has full read/write access to PostgreSQL via unrestricted SQL, so it can create tables, run queries, and store anything. Use this for things like lists, logs, structured records, or any data that's better queried than read as prose.
The bot will usually know well enough what to use when, but sometimes you will want to tell it explicitly what information to put where.
Stavrobot can message people on your behalf over Signal, Telegram, WhatsApp, or email. Need to schedule a dinner with a friend? Tell the bot to find a time that works for both of you, and it will message them, negotiate a date, and put it on your calendar. Want to arrange an appointment, coordinate a group outing, or ask someone a question while you're busy? Just tell the bot what you need and who to talk to.
The bot spins up a dedicated subagent for each conversation, with its own instructions and context, so it can handle back-and-forth with the other person without cluttering your main chat. When the task is done, it disables the contact and reports back to you.
To keep things safe, messaging requires two things before the bot can talk to someone:
/settings and add their phone number
(Signal or WhatsApp) or chat ID (Telegram). This is a one-time step per person. The
bot cannot modify this list or message anyone not on it, no matter what.A typical flow: you add your friend's phone number to the allowlist via /settings once,
then tell the bot "find a time for dinner with Alex next week". The bot creates a contact
record for Alex, spins up a subagent, messages Alex on Signal, goes back and forth to
find a date, and reports the result to you.
Skills are plain-text instruction files that teach the bot new capabilities — things like creating tables, storing knowledge, making pages, or following specific workflows. They are not code; they are natural-language instructions the bot reads and follows.
To install the skill system, tell the bot to fetch
https://stavrobot.stavros.io/skills/bootstrap.md and follow its instructions.
A full list of available skills is at
https://stavrobot.stavros.io/skills/index.md. To install a skill, just tell the bot to
fetch and install it from that URL. The bot will show you what the skill does and ask for
confirmation before proceeding.
Skill files live on the pages branch of this repo (served via Cloudflare Pages). To
contribute a skill, add a markdown file to skills/ on that branch. Each skill file has
YAML front matter with title, description, version, and an optional author,
followed by the plain-language instructions in the body. See
SKILL-AUTHORING.md for the full authoring guide. Submit a PR
against the pages branch.
See PLUGIN.md for everything you need to know to create a Stavrobot plugin. The same document works as a reference for both humans and LLMs.
Plugins are fairly necessary to enable core functionality for the bot, such as the ability to "hear" voice notes, send voice notes back, be aware of places around you, search the web, etc.
These capabilities are not provided out-of-the-box to give you the ability to customize your bot however you want. You should install plugins, though, otherwise you will be missing crucial functionality.
You can find some first-party plugins here:
https://github.com/orgs/stavrobot/repositories
Three core Docker containers: app (TypeScript server, exposes POST /chat, handles Telegram webhooks at POST /telegram/webhook, handles inbound email webhooks at POST /email/webhook, and runs WhatsApp in-process via Baileys), postgres (PostgreSQL 17 for persistent state), and plugin-runner (Node.js server — lists, inspects, and executes plugins, both locally created and git-installed). An optional coder container (Claude Code headless agent for creating and modifying editable plugins) is enabled via the coder Docker Compose profile. The main agent can create subagents, each with their own conversation history, system prompt, and tool whitelist. Interlocutors are contact records assigned to agents for inbound message routing.
341 commits
TypeScript
92.1%
Python
7.2%