Current project capabilities:
- Host side: Python 3.11+, asyncio, SQLite, Docker.
- Container side: Python, OpenAI Chat Completions, MCP stdio.
- Channels: local CLI and Telegram long polling.
- Agents: represented by
agent_group; each agent group maps to agroups/<folder>/directory. - Runtime queues: each session has its own
inbound.dbandoutbound.db. - Built-in MCP tools: send messages, send files, read/write files, run bash, schedule tasks, ask users questions, and load skills.
The overall flow is:
User
-> ChannelAdapter, such as CLI or Telegram
-> src.router.route_inbound()
-> session inbound.db
-> Docker agent container
-> OpenAI Chat Completions + MCP tools
-> session outbound.db
-> src.delivery
-> ChannelAdapter.deliver()
-> User
The project intentionally splits data into three categories:
data/v2.db: central database for global config, agents, group bindings, permissions, session indexes, pending questions, and related records.inbound.db: one per session; written by the host and read by the container.outbound.db: one per session; written by the container and read by the host.
Each session directory looks like this:
data/v2-sessions/
<agent_group_id>/
<session_id>/
inbound.db
outbound.db
.heartbeat
inbox/
outbox/
Key invariants:
inbound.dbis written only by the host.outbound.dbis written only by the container.- The host uses
processing_ackand.heartbeatto decide whether the container is processing, stuck, or should be retried. - The host writes
messages_inwith evenseqvalues. - The container writes
messages_outwith oddseqvalues.
- Python 3.11+
- Docker
- An OpenAI-compatible API key
- A Telegram bot token if you want Telegram integration
Install dependencies:
python3.11 -m venv .venv
source .venv/bin/activate
python -m pip install \
"python-telegram-bot>=21.0" \
"structlog>=24.0" \
"python-dotenv>=1.0" \
"croniter>=2.0"Run commands from the project root. The host process is started with
python -m src.main; editable package install is not required.
Build the agent image:
bash container/build.shBy default, this builds:
clawside-agent:latest
If CONTAINER_IMAGE is set, that image name is used instead.
Create .env in the project root:
OPENAI_API_KEY=sk-...
OPENAI_BASE_URL=https://api.openai.com/v1
DEFAULT_MODEL=gpt-4o
CONTAINER_IMAGE=clawside-agent:latest
CONTAINER_RUNTIME=docker
TIMEZONE=Asia/Shanghai
DATA_DIR=./data
GROUPS_DIR=./groups
ASSISTANT_NAME=Andy
CLI_SOCKET_PATH=data/clawside.sock
# Optional: required only for Telegram
TELEGRAM_BOT_TOKEN=123456789:ABC_xxxMain environment variables:
| Variable | Purpose | Default |
|---|---|---|
OPENAI_API_KEY |
API key passed into agent containers | none |
OPENAI_BASE_URL |
OpenAI-compatible API base URL | https://api.openai.com/v1 |
DEFAULT_MODEL |
Default model | gpt-4o |
CONTAINER_IMAGE |
Agent container image | clawside-agent:latest |
CONTAINER_RUNTIME |
Container runtime | docker |
TELEGRAM_BOT_TOKEN |
Enables the Telegram adapter when present | none |
TIMEZONE |
User timezone and scheduled-task timezone | UTC |
DATA_DIR |
Central DB and session DB directory | ./data |
GROUPS_DIR |
Agent group directory | ./groups |
ASSISTANT_NAME |
Default agent name | Andy |
CLI_SOCKET_PATH |
Management socket path | data/clawside.sock |
Runtime parameters:
| Variable | Purpose | Default |
|---|---|---|
ACTIVE_POLL_MS |
Delivery poll interval for running sessions | 1000 |
SWEEP_POLL_MS |
Host sweep interval | 60000 |
ABSOLUTE_CEILING_MS |
Max stale heartbeat age before a container can be killed | 1800000 |
CLAIM_STUCK_MS |
Processing-claim stuck threshold | 60000 |
MAX_TRIES |
Max message retry count | 5 |
BACKOFF_BASE_MS |
Retry backoff base | 5000 |
MAX_DELIVERY_ATTEMPTS |
Max outbound delivery attempts | 3 |
Start the host process:
python -m src.mainor:
make devOn first start, the host automatically creates:
data/v2.db- default user
cli:local - default agent group:
default - default CLI messaging group:
cli:local - CLI-to-default-agent wiring
groups/default/CLAUDE.mdgroups/default/CLAUDE.local.mdgroups/default/skills/groups/default/container.json
The CLI channel is always enabled. After startup, type a message in the terminal and press Enter; it will be sent to the default agent.
This project uses Telegram long polling, not webhooks. The server does not need to expose a public HTTPS callback URL, but it must be able to reach the Telegram API.
In Telegram, open @BotFather and send:
/newbot
BotFather will ask for two things:
- The bot display name, for example
Andy Agent. - The bot username, which must be globally unique and usually ends with
bot, for exampleandy_agent_bot.
After creation, BotFather gives you a token:
123456789:ABC_xxx
Put it in .env:
TELEGRAM_BOT_TOKEN=123456789:ABC_xxxThis token is effectively the bot password. Do not commit it to GitHub and do not publish screenshots of it.
Privacy mode only affects Telegram group chats.
When privacy mode is enabled, Telegram may only send commands, @bot messages,
and replies to the bot to your program. Plain group messages may never reach
Clawside.
If you want the bot to see normal group messages, run this in BotFather:
/setprivacy
Select your bot, then choose:
Disable
If you want the bot to respond only when it is explicitly mentioned, you can keep privacy mode enabled and use:
--engage-mode mention
If you use:
--engage-mode mention-sticky
or:
--engage-mode pattern --engage-pattern "."
then it is usually recommended to disable privacy mode in group chats, otherwise plain messages may never reach the project.
Add the bot to the target group, or open a private chat with the bot, then send a test message first.
Run:
export TELEGRAM_BOT_TOKEN="123456789:ABC_xxx"
curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates" \
| python -m json.toolIn a group, you will see:
"chat": {
"id": -1001234567890,
"type": "supergroup"
}This id is:
--telegram-chat-id "-1001234567890"
The sender block will include:
"from": {
"id": 123456,
"username": "yourname"
}Telegram user IDs in this project use this format:
telegram:<username>
So if you have a username, use:
--owner-user-id "telegram:yourname"
If there is no username, use the numeric ID:
--owner-user-id "telegram:123456"
Note: the code prefers Telegram usernames. If you have a username, do not write the owner as the numeric ID, or the permission check may not match.
If you want to use the default project agent directly:
python create_telegram_agent.py \
--agent-id default \
--agent-name "Andy" \
--agent-folder default \
--telegram-chat-id "-1001234567890" \
--owner-user-id "telegram:yourname" \
--engage-mode mention-stickyThis script creates or updates:
agent_groups: agent definition.messaging_groups: Telegram group or private chat.messaging_group_agents: binding from Telegram group to agent.users: Telegram user.user_roles: grants you owner access to this agent.agent_destinations: lets the agent send messages back to Telegram.groups/default/: default agent working directory.
Then start:
python -m src.mainIn the Telegram group, first send:
@andy_agent_bot hello
If you use mention-sticky, after the first @bot, later messages in the same
group or topic can continue into the same agent session without mentioning the
bot every time.
If you only want to use the bot through a private chat, you do not need to disable privacy mode.
In private chat, chat.id is usually a positive number, for example:
123456789
Recommended setup:
python create_telegram_agent.py \
--agent-id default \
--agent-name "Andy" \
--agent-folder default \
--telegram-chat-id "123456789" \
--owner-user-id "telegram:yourname" \
--engage-mode pattern \
--engage-pattern "."pattern "." means it matches almost any non-empty message, which works well
for private chats or dedicated bot groups.
An agent in this project is not a standalone process. It is:
an agent_groups row in the central database
+ groups/<folder>/ directory
+ container_configs config
+ messaging_group_agents binding
Create a writer agent and connect it to Telegram:
python create_telegram_agent.py \
--agent-id writer \
--agent-name "Writer" \
--agent-folder writer \
--telegram-chat-id "-1001234567890" \
--owner-user-id "telegram:yourname" \
--engage-mode mention-stickyAfter creation, you will have:
groups/writer/
CLAUDE.md
CLAUDE.local.md
container.json
skills/
You can edit:
groups/writer/CLAUDE.md
to define the agent's role, style, and rules.
CLAUDE.local.md is created as a writable long-term memory file. In the current
code, the container startup only automatically reads CLAUDE.md; it does not
automatically inject CLAUDE.local.md into the system prompt. If you want it to
be guaranteed in context every turn, instruct the agent in CLAUDE.md to read
it, or modify container/agent_runner/main.py to load it into the base prompt.
The built-in MCP server is:
container/agent_runner/mcp_servers/clawside.py
Currently implemented tools:
send_message(text, to=None): send a message.send_file(path, to=None, text="", filename=None): send a file.edit_message(message_id, text): queue a message edit.ask_user_question(title, question, options, timeout=300): ask the user a multiple-choice question.schedule_task(prompt, process_after, recurrence=None, script=None): create a scheduled task.list_tasks(status=None): list tasks.cancel_task(task_id): cancel a task.pause_task(task_id): pause a task.resume_task(task_id): resume a task.update_task(task_id, ...): update a task.read_file(path, offset=0, limit=200): read a file under/workspace.write_file(path, content): write a file.edit_file(path, old_string, new_string): replace file content.run_bash(command, timeout_ms=30000): run bash under/workspace.load_skill(name): load the full skill text.
Container tools are limited to /workspace. The container can see:
/workspace: current session directory./workspace/agent: current agent group directory./workspace/global: mounted read-only if it exists.
A skill is a directory containing SKILL.md. SKILL.md is made of YAML
frontmatter plus instruction body.
The container scans two directories on startup:
/app/skills: built-in skills fromcontainer/skills, copied into the image during build./workspace/agent/skills: custom skills for the current agent, fromgroups/<folder>/skills.
If a custom skill has the same name as a built-in skill, the custom skill overrides the built-in one.
Example:
groups/writer/skills/research/SKILL.md
---
name: research
description: Research a topic and return a concise brief
triggers:
- on: channel_type
value: telegram
---
# Research
Use available tools to gather facts, compare sources, and write a short brief.Supported triggers:
on: always: auto-load every turn.on: first_message_in_session: auto-load on the first session turn.on: channel_type+value: telegram: auto-load for a specific channel.
Skills without triggers appear in the Available Skills index, and the agent can
load the full text with load_skill(name).
The agent can call:
ask_user_question(title, question, options, timeout=300)
The host renders it as:
- CLI: numbered options.
- Telegram: inline keyboard buttons.
After the user clicks or selects an option, the host writes a kind='system'
answer message into the same session's inbound.db. The MCP tool keeps polling
for that answer until it arrives or times out.
Build:
make buildInstall development dependencies before running tests:
python -m pip install pytest pytest-asyncioStart:
make devTest:
make testThe current Makefile test target assumes a tests/ directory exists. If your
checkout does not have tests, run a compile check first:
python -m compileall src container/agent_runnerMIT License. See LICENSE.
