Build a Chief of Staff bot
In this tutorial you build a Chief of Staff: a personal assistant that lives in your Telegram, remembers what you tell it across process restarts, reaches out to you on a schedule, and reads your real GitHub — all by adding files to a directory. No SDK, no deploy step. It takes about twenty minutes.
The point isn’t the bot. It’s the pattern: your agent is a directory, and you add capabilities by dropping in files. A channel is a file. A connection is a file. Memory is a file. By the end you’ll be able to bolt on any of the hundreds of MCP servers the same way.
How to read this tutorial. Every step shows the same three things, because the whole model is change a file, watch behavior change:
- The file tree — what the agent is. The file you just added is marked
← new.- The chat — what the agent does.
»is you;«is the bot.- A log receipt, where it matters — the server line that proves it happened.
The finished agent ships at examples/chief-of-staff if you’d rather read ahead.
Prerequisites
Section titled “Prerequisites”- The
maidenbinary (cargo build --release -p maiden— see Build your first agent). The examples call it asmaiden. - A model provider key. This tutorial uses OpenAI; set
OPENAI_API_KEY. To use Anthropic instead, changemodellater — see Choose a provider. - A Telegram account.
- Docker (for the GitHub step in Act 3). If you use Colima,
colima start.
Step 1 — An agent is a directory
Section titled “Step 1 — An agent is a directory”Make the folder and the two files every agent needs — how it thinks
(instructions.md) and how it’s configured (agent.toml):
mkdir -p chief-of-staffchief-of-staff/agent.toml:
[agent]model = "openai/gpt-4o-mini"instructions_file = "instructions.md"max_turns = 16chief-of-staff/instructions.md:
You are Chief of Staff, a personal assistant that lives in Telegram. Be short andscannable — lead with what needs action, use compact bullets, skip the preamble.Always fetch live from your tools; never guess. Reading is free, but any actionthat writes or sends must be previewed first and only done after the user repliesCONFIRM.That’s the whole shape:
chief-of-staff/ agent.toml ← new instructions.md ← newEverything else in this tutorial is another file in this tree. Keep that frame.
Step 2 — Give it a Telegram channel
Section titled “Step 2 — Give it a Telegram channel”A channel is an inbound surface — it turns a chat app into the agent’s front door. Add one file:
chief-of-staff/channels/telegram.toml:
kind = "telegram"token_env = "TELEGRAM_BOT_TOKEN"allow_from = ["YOUR_NUMERIC_ID"]Two values to fill in, both from Telegram itself:
- A bot token — DM @BotFather, send
/newbot, copy the token it gives you. - Your numeric id — DM @userinfobot; put that
number in
allow_fromso the bot answers only to you.token_envis the name of the environment variable, never the token itself.
Boot it:
export TELEGRAM_BOT_TOKEN="123456:ABC..."maiden serve chief-of-staffmaiden serve: agent 'chief-of-staff' (0 tools: ) on http://127.0.0.1:8080[trigger] channel 'telegram' telegram (token env 'TELEGRAM_BOT_TOKEN')That last line is the receipt: the channel is live and long-polling. DM your bot.
» hey, who are you?« I'm your Chief of Staff — I keep you on top of what matters. Right now I can chat; give me tools and I'll start doing real work.chief-of-staff/ agent.toml instructions.md channels/ telegram.toml ← newIt talks. It doesn’t remember anything yet — restart the server and it’s a blank slate. Let’s fix that.
Step 3 — Give it memory
Section titled “Step 3 — Give it memory”Memory is built in. Turn it on with one line in agent.toml:
[agent]model = "openai/gpt-4o-mini"instructions_file = "instructions.md"memory = true # ← the remember/recall tools + durable recallThat does two things. It advertises two tools to the model — remember (save a
fact) and recall (list what’s saved) — and it injects everything saved into the
top of the system prompt on every turn, under a “Remembered” heading. So a
saved preference is honored whether or not the model thinks to look it up.
Restart the server and teach it something — no magic prefix, just talk:
» remember I don't care about Dependabot« Got it — I'll skip Dependabot from now on.» what do you remember?« - I don't care about Dependabotchief-of-staff/ agent.toml ← +1 line: memory = true instructions.md channels/ telegram.tomlBehind the scenes the model called remember("I don't care about Dependabot"),
which appended the note to the thread’s durable state — not the context
window. Which sets up the payoff.
When one line isn’t enough. Need bespoke per-turn logic — pick a model by topic, gate the tool set, shape the prompt from live state? Drop a dynamic resolver (a script under
dynamic/, see the agent folder reference) that runs at the start of every turn. Built-in memory is the batteries-included case; resolvers are the escape hatch.
Step 4 — Prove it’s durable
Section titled “Step 4 — Prove it’s durable”Here’s what makes maiden a backend runtime and not a chat wrapper. Kill the server completely — Ctrl-C, the process is gone, nothing in memory:
# ^Cmaiden serve chief-of-staffNow DM a fresh question — nothing to jog its memory:
» what's my take on dependabot?« You said you don't care about Dependabot.That answer came from a process that didn’t exist when you told it the fact. It booted, read the thread off disk, and continued as if nothing happened. The proof is a file:
chief-of-staff/ .maiden/ state/ telegram_<your-id>.json ← durable thread state{ "history": [ ... ], "state": { "memory.notes": ["I don't care about Dependabot"] }, ... }Every thread is checkpointed after every turn. Crash it, redeploy it, move it to another machine — the agent resumes. See Execution model and durability for how the checkpoint works.
Step 5 — Let it reach out (an outbound connection)
Section titled “Step 5 — Let it reach out (an outbound connection)”So far the bot only reacts. To let it message you first, give it an outbound tool. A connection is an outbound surface; the simplest kind wraps a REST API from an OpenAPI spec. Two files: the spec, and the connection that mounts it.
chief-of-staff/telegram-send.json — a one-operation slice of the Telegram API:
{ "openapi": "3.0.0", "info": { "title": "Telegram (send only)", "version": "1.0.0" }, "paths": { "/sendMessage": { "get": { "operationId": "sendMessage", "parameters": [ { "name": "chat_id", "in": "query", "required": true, "schema": { "type": "string" } }, { "name": "text", "in": "query", "required": true, "schema": { "type": "string" } } ], "responses": { "200": { "description": "Message sent." } } } } }}chief-of-staff/connections/telegram-send.toml:
kind = "openapi"spec = "./telegram-send.json"base_url = "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}"operations.allow = ["sendMessage"]The ${TELEGRAM_BOT_TOKEN} is substituted from the environment at load time, so
the token never lives in a file. operations.allow means the model gets exactly
one tool from this spec — sendMessage — and nothing else. Restart:
maiden serve: agent 'chief-of-staff' (1 tools: sendMessage) on http://127.0.0.1:8080chief-of-staff/ ... telegram-send.json ← new connections/ telegram-send.toml ← newThe tool count went from 0 to 1. Now the agent can push you a message. The next step gives it a reason to.
Step 6 — Make it proactive (a schedule)
Section titled “Step 6 — Make it proactive (a schedule)”A schedule fires a prompt on a cron cadence with no human in the loop. Add one file.
chief-of-staff/schedules/morning-digest.toml:
cron = "0 7 * * *"prompt = """It's the morning briefing. Compose a short greeting and SEND it to me by callingthe sendMessage tool with chat_id="YOUR_NUMERIC_ID" and your message as text.Send exactly one message."""At 07:00 the runtime drives that prompt; the agent composes a message and calls
sendMessage. You wake up to:
« 🗓 Morning — Thursday. Nothing on fire. Have a good one.chief-of-staff/ ... schedules/ morning-digest.toml ← newThe digest is thin because the agent still can’t see anything. Cadence and channels are covered in depth in Schedules and channels. Time to give it eyes.
Step 7 — Read your real GitHub (an MCP connection)
Section titled “Step 7 — Read your real GitHub (an MCP connection)”This is where the pattern pays off. GitHub ships an official MCP server; maiden speaks the Model Context Protocol, so you mount it as one more connection file — and maiden itself contains zero GitHub-specific code.
chief-of-staff/connections/github.toml:
kind = "mcp-stdio"command = "docker"args = ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server", "--read-only"]Create a fine-grained, read-only Personal Access Token (Contents, Issues, Pull requests, Metadata → Read), export it, make sure Docker is running, restart:
export GITHUB_PERSONAL_ACCESS_TOKEN="github_pat_..."maiden serve chief-of-staffOn boot, maiden spawns the container, does the MCP handshake, asks the server what tools it has, and registers each one:
GitHub MCP Server running on stdiosession initializedmaiden serve: agent 'chief-of-staff' (23 tools: get_me, list_pull_requests, search_issues, list_notifications, ... , sendMessage) on http://127.0.0.1:8080chief-of-staff/ ... connections/ telegram-send.toml github.toml ← newOne file, and the model gained a couple dozen GitHub tools next to sendMessage
— it never knows “GitHub” is a container over a pipe. The mechanics of MCP
connections (stdio vs HTTP, auth, tool filtering) are in
Connect an MCP server.
Step 8 — The safety model
Section titled “Step 8 — The safety model”Notice you passed --read-only, and the token is read-only, and the instructions
say CONFIRM before writes. That’s three independent layers, on purpose:
- Read-only PAT — the hard boundary. GitHub itself rejects any write, so the agent physically cannot change your repos, whatever it’s asked.
--read-onlyserver — the MCP server never even advertises write tools, so a destructive action isn’t in the model’s vocabulary.CONFIRMin instructions — the soft boundary for when you later widen scope: any write is previewed and waits for you to replyCONFIRM.
Keep the PAT read-only until you actually want the bot to act; then relax the outer layers deliberately, one at a time. More in Security model.
One connection failing never takes the agent down. If Docker is stopped or the token is unset, that connection is skipped with a diagnostic and the agent boots with everything else:
[connection 'github'] skipped: spawn 'docker' failed — is it installed and on PATH?Step 9 — Ask it something real
Section titled “Step 9 — Ask it something real”With GitHub connected, ask a live question:
» anything need my review?« 🔴 Needs you (1) 1. jondot/picocode #4 — "Proposal: Add Aurelo as a first-class model provider"» who opened that?« It was opened by 1abhayrajsinghbhadauriya-dev.[trigger telegram:<your-id>] ran (1 turn(s))The agent called search_issues / list_pull_requests against your real
account, then answered in the channel — reading live, never guessing, exactly as
the instructions demand.
Step 10 — Make it yours
Section titled “Step 10 — Make it yours”You now know the only move there is: a capability is a file. To add Google
Calendar and Gmail, drop in another mcp-stdio connection pointed at a Google
Workspace MCP server — same shape as github.toml. To add Linear, Slack, a
database, or your own service, find (or write) an MCP server and mount it. The
instructions and the digest prompt already tell the agent to fan out across
whatever it can see.
chief-of-staff/ agent.toml instructions.md telegram-send.json channels/ telegram.toml connections/ telegram-send.toml github.toml google.toml ← your next file schedules/ morning-digest.tomlOne scope note: maiden connections authenticate with static tokens and keys, not interactive OAuth flows. Servers that require a browser OAuth dance (some Google setups) do their own token handling out of band — that part is deliberately out of maiden’s scope.
Where to go next
Section titled “Where to go next”You built a durable, proactive, tool-using backend agent — entirely out of files in a directory. To go deeper:
- Mount more services → Connect an MCP server
- Tune cadence and channels → Schedules and channels
- Understand the guarantees → Execution model and durability
- Lock it down → Security model
- Every file explained → Agent folder layout
If something doesn’t line up
Section titled “If something doesn’t line up”The model 'X' does not exist or you do not have access to it— the model inagent.tomlisn’t on your key. List what you have (curl -s https://api.openai.com/v1/models -H "Authorization: Bearer $OPENAI_API_KEY") and setmodelto one of those. (A--mockrun never catches this — only a real call does.)- Docker won’t start (Colima “disk in use” / stale lock) — force-stop and
restart the VM:
colima stop -f && colima start. - The bot doesn’t answer — check
allow_fromholds your numeric id, and thatTELEGRAM_BOT_TOKENis exported in the shell that runsmaiden serve.