Skip to content

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.

  • The maiden binary (cargo build --release -p maiden — see Build your first agent). The examples call it as maiden.
  • A model provider key. This tutorial uses OpenAI; set OPENAI_API_KEY. To use Anthropic instead, change model later — see Choose a provider.
  • A Telegram account.
  • Docker (for the GitHub step in Act 3). If you use Colima, colima start.

Make the folder and the two files every agent needs — how it thinks (instructions.md) and how it’s configured (agent.toml):

Terminal window
mkdir -p chief-of-staff

chief-of-staff/agent.toml:

[agent]
model = "openai/gpt-4o-mini"
instructions_file = "instructions.md"
max_turns = 16

chief-of-staff/instructions.md:

You are Chief of Staff, a personal assistant that lives in Telegram. Be short and
scannable — lead with what needs action, use compact bullets, skip the preamble.
Always fetch live from your tools; never guess. Reading is free, but any action
that writes or sends must be previewed first and only done after the user replies
CONFIRM.

That’s the whole shape:

chief-of-staff/
agent.toml ← new
instructions.md ← new

Everything else in this tutorial is another file in this tree. Keep that frame.

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:

  1. A bot token — DM @BotFather, send /newbot, copy the token it gives you.
  2. Your numeric id — DM @userinfobot; put that number in allow_from so the bot answers only to you. token_env is the name of the environment variable, never the token itself.

Boot it:

Terminal window
export TELEGRAM_BOT_TOKEN="123456:ABC..."
maiden serve chief-of-staff
maiden 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 ← new

It talks. It doesn’t remember anything yet — restart the server and it’s a blank slate. Let’s fix that.

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 recall

That 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 Dependabot
chief-of-staff/
agent.toml ← +1 line: memory = true
instructions.md
channels/
telegram.toml

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

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:

Terminal window
# ^C
maiden serve chief-of-staff

Now 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:8080
chief-of-staff/
...
telegram-send.json ← new
connections/
telegram-send.toml ← new

The tool count went from 0 to 1. Now the agent can push you a message. The next step gives it a reason to.

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 calling
the 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 ← new

The 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:

Terminal window
export GITHUB_PERSONAL_ACCESS_TOKEN="github_pat_..."
maiden serve chief-of-staff

On 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 stdio
session initialized
maiden serve: agent 'chief-of-staff' (23 tools: get_me, list_pull_requests,
search_issues, list_notifications, ... , sendMessage) on http://127.0.0.1:8080
chief-of-staff/
...
connections/
telegram-send.toml
github.toml ← new

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

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:

  1. Read-only PAT — the hard boundary. GitHub itself rejects any write, so the agent physically cannot change your repos, whatever it’s asked.
  2. --read-only server — the MCP server never even advertises write tools, so a destructive action isn’t in the model’s vocabulary.
  3. CONFIRM in instructions — the soft boundary for when you later widen scope: any write is previewed and waits for you to reply CONFIRM.

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?

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.

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

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

You built a durable, proactive, tool-using backend agent — entirely out of files in a directory. To go deeper:


  • The model 'X' does not exist or you do not have access to it — the model in agent.toml isn’t on your key. List what you have (curl -s https://api.openai.com/v1/models -H "Authorization: Bearer $OPENAI_API_KEY") and set model to one of those. (A --mock run 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_from holds your numeric id, and that TELEGRAM_BOT_TOKEN is exported in the shell that runs maiden serve.