skorokithakis/stavrobot

An AI personal assistant with a focus on security.

TypeScript

185

341 commits

updated Sep 22, 2026

See the code

README

Stavrobot

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.

Features

  • Secure. Everything runs in a container, your host OS is completely invisible to the AI. The AI doesn't see any secrets unless you want it to.
  • Light. Doesn't run one container per component. Plugins all run in a single container, separated by Unix permissions.
  • Use any model you want. Local, OpenRouter, Anthropic/OpenAI. Whatever Pi supports, the bot can use.
  • Signal/Telegram/WhatsApp/Email integration. Two-way messaging, with formatting, attachments, etc.
  • Pebble Index integration. Ring recordings can be queued directly for the agent.
  • Three-tier knowledge. Remembers everything without blowing out its context. Intelligently and transparently retrieves data from its memory.
  • Low token usage. Various optimizations have been made to be light on token usage. It even uses TOON internally.
  • Plugins. Install plugins and extend Stavrobot's capabilities by just giving it a git repo URL. Plugins are isolated from each other — each runs as a dedicated system user with no access to other plugins' files or configuration.
  • Subagents. The main agent can create subagents with their own conversation context, system prompt, and restricted tool access. Useful for talking to outside people to complete tasks or arrange things, while having a buffer between the outside person and the main agent.
  • Self-programming. The agent can program and extend itself.
  • Sandboxed Python execution. Arbitrary Python with pip dependencies via uv, isolated from the host environment.
  • Scheduling. The agent can schedule its own recurring or one-shot tasks.

Quickstart

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:

  1. 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).

  2. Copy env.example to .env and change POSTGRES_PASSWORD to something secure and TZ to your timezone.

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

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

Setup

Config

  1. Copy config.example.toml to data/main/config.toml.
  2. Fill in API keys and settings. The example file has comments explaining each section.
  3. At minimum, set authFile (or apiKey) and publicHostname. Everything else is optional.
  4. Copy 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.

Config editor

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.

Custom and OpenAI-compatible endpoints

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.

Authentication

The app supports two authentication modes: API key or OAuth.

  • API key: Set apiKey in config.toml. No login or logout needed.
  • OAuth: Set authFile in config.toml (a path where credentials will be stored). The login page works with any OAuth provider supported by Pi.
    • Login: Visit <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.
    • Logout: Delete the file at the authFile path. The bot will detect missing credentials on the next message and prompt you to log in again.

Claude Code setup

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

  1. Set 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).
  2. Start the containers: docker compose --profile coder up --build
  3. Log in: docker compose exec -u coder coder claude (it will prompt you to log in if you haven't).
  4. Follow the browser-based OAuth flow.
  5. Set [coder].model in your config to a Claude Code model alias (sonnet, opus, or haiku).

Signal setup

Signal requires a separate phone number — not your personal one. A prepaid SIM or VoIP number works.

  1. Uncomment COMPOSE_PROFILES=signal in your .env file to enable the signal-bridge container.
  2. Build the containers: docker compose --profile signal build
  3. Register Signal on the container (pick one):
    • Link to an existing Signal account: docker 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).
    • Register a new number: 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'.
  4. Set [signal].account in your config.
  5. Start the containers: docker compose up --build
  6. After first startup, add allowed numbers via the /settings web UI.
  7. Important: signal-cli does not resolve phone numbers for contacts it hasn't messaged yet. The bot must send the first message to each contact by phone number before it can receive and identify incoming messages from them. To trigger this for the owner, run:
    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)"
    
  8. See the signal-cli quickstart for details.

Telegram setup

  1. Message @BotFather on Telegram, create a new bot, and copy the token.
  2. Message @userinfobot on Telegram to get your user ID (this is used as the chat ID).
  3. Set [telegram].botToken in your config.
  4. Set the chat ID (not your mobile phone number) in [owner].telegram in your config.
  5. After first startup, add allowed chat IDs via the /settings web UI.
  6. The webhook is registered automatically when the app starts.

WhatsApp setup

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.

  1. Add a [whatsapp] section to your config.toml (see config.example.toml for the format).
  2. Start the containers: docker compose up --build
  3. A QR code will appear in the app logs (docker compose logs -f app).
  4. Open WhatsApp on your phone, go to Linked devices, and scan the QR code.
  5. The session persists across restarts in ./data/whatsapp.
  6. Add allowed phone numbers via the /settings web UI.

Email setup

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.

  1. Add an [email] section to your config.toml with SMTP credentials and a random webhookSecret.
  2. Deploy the Cloudflare Email Worker (code in config.example.toml) and set the WEBHOOK_URL and WEBHOOK_SECRET environment variables on the worker.
  3. In Cloudflare Email Routing, add a rule to forward inbound mail to the worker.
  4. Add allowed sender addresses via the /settings web UI.

Pebble Index setup

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.

Running

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.

Without Docker

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.

HTTP API

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 /chat

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

FieldTypeDescription
messagestringThe text to send to the agent.
asyncbooleanOptional. 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.
sourcestringThe 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.
senderstringWho 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.
filesarrayFiles 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.
attachmentsarrayFiles 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:

  • Absent, or one of 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.
  • Anything else: goes to the main agent with the source name shown to the agent. No allowlist check.

Two special cases:

  • If the message is exactly /stop, the running agent turn is aborted. Nothing is queued. A synchronous request receives Aborted..
  • If the agent is busy with one of your messages and another message from you arrives, the new message is injected into the running turn instead of being queued. A synchronous steering request receives 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/upload

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

Webhooks

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

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.

Database explorer

  • 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).

Plugin management

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.

Allowlist

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

Login and Signal captcha

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

Web UI pages

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.

Knowledge system

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.

Talking to other people

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:

  1. You add them to the allowlist. Go to /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.
  2. The bot creates a contact record. When you ask the bot to message someone on the allowlist, it creates a contact record and spins up a dedicated subagent for the conversation. When the task is done, it disables the contact, which blocks messaging in both directions until you ask the bot to re-enable it.

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

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.

How to make your own plugin

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

Architecture

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.

License

AGPL-3.0

Contributors

skorokithakis

341 commits

skorokithakis/stavrobot

An AI personal assistant with a focus on security.

TypeScript

185

341 commits

updated Sep 22, 2026

See the code

README

Stavrobot

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.

Features

  • Secure. Everything runs in a container, your host OS is completely invisible to the AI. The AI doesn't see any secrets unless you want it to.
  • Light. Doesn't run one container per component. Plugins all run in a single container, separated by Unix permissions.
  • Use any model you want. Local, OpenRouter, Anthropic/OpenAI. Whatever Pi supports, the bot can use.
  • Signal/Telegram/WhatsApp/Email integration. Two-way messaging, with formatting, attachments, etc.
  • Pebble Index integration. Ring recordings can be queued directly for the agent.
  • Three-tier knowledge. Remembers everything without blowing out its context. Intelligently and transparently retrieves data from its memory.
  • Low token usage. Various optimizations have been made to be light on token usage. It even uses TOON internally.
  • Plugins. Install plugins and extend Stavrobot's capabilities by just giving it a git repo URL. Plugins are isolated from each other — each runs as a dedicated system user with no access to other plugins' files or configuration.
  • Subagents. The main agent can create subagents with their own conversation context, system prompt, and restricted tool access. Useful for talking to outside people to complete tasks or arrange things, while having a buffer between the outside person and the main agent.
  • Self-programming. The agent can program and extend itself.
  • Sandboxed Python execution. Arbitrary Python with pip dependencies via uv, isolated from the host environment.
  • Scheduling. The agent can schedule its own recurring or one-shot tasks.

Quickstart

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:

  1. 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).

  2. Copy env.example to .env and change POSTGRES_PASSWORD to something secure and TZ to your timezone.

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

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

Setup

Config

  1. Copy config.example.toml to data/main/config.toml.
  2. Fill in API keys and settings. The example file has comments explaining each section.
  3. At minimum, set authFile (or apiKey) and publicHostname. Everything else is optional.
  4. Copy 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.

Config editor

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.

Custom and OpenAI-compatible endpoints

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.

Authentication

The app supports two authentication modes: API key or OAuth.

  • API key: Set apiKey in config.toml. No login or logout needed.
  • OAuth: Set authFile in config.toml (a path where credentials will be stored). The login page works with any OAuth provider supported by Pi.
    • Login: Visit <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.
    • Logout: Delete the file at the authFile path. The bot will detect missing credentials on the next message and prompt you to log in again.

Claude Code setup

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

  1. Set 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).
  2. Start the containers: docker compose --profile coder up --build
  3. Log in: docker compose exec -u coder coder claude (it will prompt you to log in if you haven't).
  4. Follow the browser-based OAuth flow.
  5. Set [coder].model in your config to a Claude Code model alias (sonnet, opus, or haiku).

Signal setup

Signal requires a separate phone number — not your personal one. A prepaid SIM or VoIP number works.

  1. Uncomment COMPOSE_PROFILES=signal in your .env file to enable the signal-bridge container.
  2. Build the containers: docker compose --profile signal build
  3. Register Signal on the container (pick one):
    • Link to an existing Signal account: docker 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).
    • Register a new number: 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'.
  4. Set [signal].account in your config.
  5. Start the containers: docker compose up --build
  6. After first startup, add allowed numbers via the /settings web UI.
  7. Important: signal-cli does not resolve phone numbers for contacts it hasn't messaged yet. The bot must send the first message to each contact by phone number before it can receive and identify incoming messages from them. To trigger this for the owner, run:
    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)"
    
  8. See the signal-cli quickstart for details.

Telegram setup

  1. Message @BotFather on Telegram, create a new bot, and copy the token.
  2. Message @userinfobot on Telegram to get your user ID (this is used as the chat ID).
  3. Set [telegram].botToken in your config.
  4. Set the chat ID (not your mobile phone number) in [owner].telegram in your config.
  5. After first startup, add allowed chat IDs via the /settings web UI.
  6. The webhook is registered automatically when the app starts.

WhatsApp setup

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.

  1. Add a [whatsapp] section to your config.toml (see config.example.toml for the format).
  2. Start the containers: docker compose up --build
  3. A QR code will appear in the app logs (docker compose logs -f app).
  4. Open WhatsApp on your phone, go to Linked devices, and scan the QR code.
  5. The session persists across restarts in ./data/whatsapp.
  6. Add allowed phone numbers via the /settings web UI.

Email setup

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.

  1. Add an [email] section to your config.toml with SMTP credentials and a random webhookSecret.
  2. Deploy the Cloudflare Email Worker (code in config.example.toml) and set the WEBHOOK_URL and WEBHOOK_SECRET environment variables on the worker.
  3. In Cloudflare Email Routing, add a rule to forward inbound mail to the worker.
  4. Add allowed sender addresses via the /settings web UI.

Pebble Index setup

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.

Running

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.

Without Docker

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.

HTTP API

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 /chat

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

FieldTypeDescription
messagestringThe text to send to the agent.
asyncbooleanOptional. 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.
sourcestringThe 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.
senderstringWho 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.
filesarrayFiles 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.
attachmentsarrayFiles 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:

  • Absent, or one of 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.
  • Anything else: goes to the main agent with the source name shown to the agent. No allowlist check.

Two special cases:

  • If the message is exactly /stop, the running agent turn is aborted. Nothing is queued. A synchronous request receives Aborted..
  • If the agent is busy with one of your messages and another message from you arrives, the new message is injected into the running turn instead of being queued. A synchronous steering request receives 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/upload

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

Webhooks

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

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.

Database explorer

  • 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).

Plugin management

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.

Allowlist

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

Login and Signal captcha

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

Web UI pages

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.

Knowledge system

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.

Talking to other people

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:

  1. You add them to the allowlist. Go to /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.
  2. The bot creates a contact record. When you ask the bot to message someone on the allowlist, it creates a contact record and spins up a dedicated subagent for the conversation. When the task is done, it disables the contact, which blocks messaging in both directions until you ask the bot to re-enable it.

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

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.

How to make your own plugin

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

Architecture

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.

License

AGPL-3.0

Contributors

skorokithakis

341 commits

Languages

TypeScript

92.1%

Python

7.2%