Balda exists to give teams a persistent AI worker they can assign real work to.
It takes work from the team's conversation, keeps project context, uses the team's tools, and keeps moving until there is a concrete result to review. A task can start as a message, a topic, a goal, a schedule, or an external event; Balda turns that intent into an active worker session.
The name comes from Pushkin's работник Балда: practical, direct, and focused on getting the job done. That is the project goal: an autonomous worker comrade for teams.
npm install -g -y @normahq/balda
balda init
balda start| Feature | What it means |
|---|---|
| Assignable work | People can give Balda a task or goal the same way they would assign work to a teammate. |
| Persistent worker context | Balda remembers team and project facts and carries task context across sessions, interruptions, and restarts. |
| Team conversation as work intake | Work can start from chat, topics, mentions, commands, schedules, or incoming external events. |
| Focused work sessions | Separate threads/topics can become separate work contexts, so different tasks do not collapse into one conversation. |
| Autonomous execution loops | Balda can keep working toward a goal, validate progress, and continue until there is a result. |
| Project tool access | Balda can use the same project tools the team relies on, including repo/workspace operations and configured integrations. |
| Event-driven work | Webhooks and scheduled triggers become inputs to agents, not just notifications. |
| Collaborative visibility | The team can see progress, cancel work, and manage who can assign work. |
| Reviewable outcomes | Balda should return something the team can evaluate: a summary, changed files, a commit, validation output, or a next action. |
| Operationally simple deployment | Teams can run Balda close to their project without building a platform first. |
- Pick a provider runtime.
- Connect a Telegram bot token, a Zulip outgoing webhook bot, or an internal Slack app.
- Chat, create topics, and let Balda persist session state, memory, and workspaces.
Balda runs one provider runtime per process and maps chat conversations to separate agent sessions. Each Telegram topic, Zulip stream+topic pair, or Slack thread becomes an isolated session. That keeps the bot simple to operate while preserving session boundaries.
You need:
- a Telegram bot token from BotFather, a Zulip outgoing webhook bot, or an internal Slack app
- at least one provider CLI for host installs:
codex,opencode,copilot,gemini, orclaude - Node.js/npm, unless you use the Docker Compose flow
Install Balda:
npm install -g -y @normahq/baldaInitialize Balda in your project:
balda initbalda init detects provider CLIs, validates the Telegram token, writes
.config/balda/config.yaml, initializes .config/balda/state.db, and prints
the next commands. By default, the Telegram token is stored in .env.
Start Balda:
balda startLocal repo development helper:
task devRun fake ingress scenarios (Telegram/webhook/scheduler command publish paths):
task scenariosInspect the runtime streams and consumers:
task runtime-stateReplay projection events through the deterministic projector replay suite:
task projection-replayAuthenticate in Telegram with the printed auth link, or send the printed command directly to your bot:
/start owner=<owner_token>
After owner auth, send a normal direct message to start the bot's main DM session. Create a named topic session when you want an isolated workspace and conversation:
/topic <name>
Balda ships a root Dockerfile and compose.yaml
for local Docker Compose runtime.
This path is designed for real project work. The current directory is mounted as
/workspace, so Balda sees your host checkout, .git, .env,
.config/balda/config.yaml, and .config/balda/state.db.
docker compose build balda
docker compose run --rm balda init
docker compose up -d baldaProvider credentials are not baked into the image. Authenticate with provider
environment variables or provider login commands run through Compose.
balda-home persists provider CLI home config across container recreates.
Polling mode is the default and does not require publishing a port. Webhook
setup and image details are documented in docs/balda.md.
Balda also publishes an official container image to
ghcr.io/normahq/balda:latest.
This image is built from the tagged source tree with a separate
Dockerfile.release. Unlike the local Compose image, the
published GHCR image is a minimal carrier for the balda binary only. It does
not bundle codex, opencode, copilot, gemini, or claude.
Use it as a source stage in your own multistage Dockerfile, then add only the
provider CLI runtime you want in the final image. For example, a Codex-based
runtime can copy balda from GHCR and install Codex separately:
FROM node:24-bookworm-slim AS cli-builder
RUN npm install -g @openai/codex
FROM ghcr.io/normahq/balda:latest AS balda
FROM node:24-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
git \
openssh-client \
ripgrep \
&& rm -rf /var/lib/apt/lists/*
COPY --from=cli-builder /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=cli-builder /usr/local/bin/codex /usr/local/bin/codex
COPY --from=balda /usr/local/bin/balda /usr/local/bin/balda
WORKDIR /workspace
ENTRYPOINT ["balda"]Local Docker Compose still uses the root Dockerfile and
compose.yaml, which remain the bundled-CLI runtime for
workspace-oriented local project work.
Balda has built-in provider types for common CLIs and a generic provider adapter for anything else that speaks the same runtime protocol.
runtime:
providers:
my-agent:
type: generic_acp
generic_acp:
cmd: ["my-acp-agent", "--stdio"]
model: "my-model"
balda:
provider: my-agentBuilt-in provider types:
codex_acpopencode_acpcopilot_acpgemini_acpclaude_code_acpgeneric_acppool
/start owner=<owner_token>: authenticate the owner in direct messages./start invite=<invite_token>: onboard a collaborator in direct messages./start <balda_token>: connect this Telegram account to the existing owner when using a generated channel token./topic <name>: owner/collaborator, direct messages only; create a named topic session./goal <objective>: owner/collaborator; start goal work from the current session context in isolated GoalKeeper worker/validator ADK sessions. With workspace mode enabled, Balda creates a goal workspace frombalda.workspace.base_branch, exports it back automatically on success, and preserves it for recovery if export fails. With workspace mode disabled, GoalKeeper works directly inbalda.working_dirand recordsnot_exportedon success. Goal updates usebalda.telegram.formatting_mode; terminal updates include concise result, export, work, validation, and actionable next-step sections when needed. Only one/goalrun can be active per session. See the goal workflow doc./goal clear: owner/collaborator; stop active/goalwork for the current session only./reset,/restart: owner/collaborator; cancel current session work, clear the current session history, and immediately start a fresh runtime session without closing the chat or topic. Works in any current session context./locator: owner/collaborator; show the current transport type and a pasteable locator ref for scheduler/webhooktarget: locatorconfig./close: owner/collaborator, direct messages only; reset the current session history. In a topic, it also closes that topic./cancel: owner/collaborator; cancel the current session turn and drop queued turns for that session. It does not stop active/goalwork./user add: owner only; generate a collaborator invite link./user list: owner only; list collaborators and active invites./user remove <user_id>: owner only; remove a collaborator by user ID.
Balda loads .config/balda/config.yaml, then applies BALDA_* environment
overrides. If .env exists in the working directory, Balda loads it before
config resolution.
Minimal shape:
runtime:
providers:
<provider_id>:
# generic_acp | gemini_acp | codex_acp | opencode_acp | copilot_acp | claude_code_acp | pool
type: <provider_type>
codex_acp:
# Optional Codex reasoning effort.
reasoning_effort: <minimal|low|medium|high|xhigh>
mcp_servers: {}
balda:
provider: <provider_id>
telegram:
token: ""
formatting_mode: "rich_markdown"
plan_updates: true
webhook:
enabled: false
listen_addr: "0.0.0.0:8080"
path: "/telegram/webhook"
url: ""
auth_token: ""
zulip:
bot_email: ""
api_key: ""
server_url: ""
webhook_token: ""
webhook:
enabled: false
listen_addr: "0.0.0.0:8090"
path: "/zulip/webhook"
slack:
enabled: false
bot_token: ""
signing_secret: ""
listen_addr: "0.0.0.0:8091"
events_path: "/slack/events"
commands_path: "/slack/commands"
include_private_channels: false
webhooks:
enabled: false
listen_addr: "127.0.0.1:8090"
routes: {}
logger:
level: "info"
pretty: true
working_dir: ""
state_dir: ".config/balda"
sessions:
persistence: "sqlite"
memory:
enabled: true
goal:
max_iterations: 25
nats:
embedded: true
host: "127.0.0.1"
port: -1
max_memory: "256mb"
max_store: "2gb"
sync_always: false
expose_monitoring: false
swarm: {}
scheduler:
tasks: []
workspace:
mode: "auto"
base_branch: ""
sessions_dir: "sessions"
mcp_servers: []
global_instruction: ""Common settings:
balda.provider: provider ID selected duringbalda init.balda.telegram.token: Telegram bot token, usually supplied by.envasBALDA_TELEGRAM_TOKEN.balda.zulip.bot_email,balda.zulip.api_key,balda.zulip.server_url: Zulip outgoing webhook bot credentials.server_urlmust be an absolutehttp://orhttps://URL. Seedocs/zulip-webhook.mdfor setup steps.balda.zulip.webhook_token: verification token from the Zulip outgoing webhook bot settings.balda.zulip.webhook.enabled: settrueto start the Zulip webhook receiver onlisten_addr. When this istrue, a Telegram token is not required — Balda can run Zulip-only.balda.slack.enabled: settrueto start the Slack HTTP receiver. Balda serves plain HTTP only; HTTPS termination and public Request URL routing are external deployment concerns. Seedocs/slack.md.balda.slack.bot_token: Slack bot token (xoxb-...), usually supplied asBALDA_SLACK_BOT_TOKEN.balda.slack.signing_secret: Slack signing secret used to verify Events API and slash command requests, usually supplied asBALDA_SLACK_SIGNING_SECRET.balda.telegram.webhook.auth_token: required when Telegram webhook mode is enabled; Telegram sends it asX-Telegram-Bot-Api-Secret-Token.balda.webhooks.*: optional local inbound webhook receiver for external event-to-session ingress. Each route definespath,prompt_template,envelope(target,key, optionalmode=task|session, optionalreport_to),auth(type=none|header,header,valueorsecret_env), anddedupe(source=request_id|header|body_sha256, optionalheaderfor header source). Usetarget: locatorwith a/locatorvalue inkeyto route directly to a specific session context.balda.webhooks.*security: set routeauth(for example shared-token header) and keeplisten_addrprivate (localhost/private network) or front it with trusted gateway auth.balda.sessions.persistence:sqliteby default; keeps conversation history across restarts until the session is explicitly closed.balda.memory.enabled:trueby default; controls${balda.state_dir}/MEMORY.mdandbalda.memory.*MCP tools.balda.goal.max_iterations: maximum/goalworker-validator loop iterations; defaults to25.balda.nats.*: built-in command/event runtime settings. Defaults bind to127.0.0.1on a random local port, keep monitoring disabled, and store runtime files under${balda.state_dir}/nats.balda.swarm: optional advanced runtime tuning for goals, scheduled work, retries, and webhook delivery. Most installs should leave it at defaults.balda.scheduler.tasks: startup-reconciled recurring tasks. Each task hasid,cron, andenvelopewithtarget,key,content, and optionalreport_to. Scheduled work publishes first-class task commands; replies are fire-and-forget unlessreport_tois set. Usetarget: locatorwith a/locatorvalue inkeyto target a specific session.balda.workspace.mode:autoby default; uses git worktrees when Balda runs in a git repository.balda.workspace.sessions_dir: directory name underbalda.state_dirused for per-session worktrees (defaults tosessions).balda.mcp_servers: extra MCP server IDs added to every Balda-started session.runtime.providers.<id>.codex_acp.reasoning_effort: optional Codex reasoning effort. Balda passes this through to Norma, which maps it to Codex ACP session startup/resume config.
MCP servers can be attached to providers or injected into every Balda session.
Balda also includes a built-in balda MCP server for memory and workspace
tools.
runtime:
mcp_servers:
local-tools:
type: stdio
cmd: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
remote-tools:
type: http
url: https://mcp.example.com/mcp
providers:
codex:
type: codex_acp
codex_acp:
reasoning_effort: high
mcp_servers:
- local-tools
balda:
provider: codex
mcp_servers:
- remote-toolsEffective MCP IDs are built-in balda + provider mcp_servers + balda.mcp_servers.
Do not define runtime.mcp_servers.balda; Balda owns that bundled server.
telegram token is required: runbalda init, setBALDA_TELEGRAM_TOKENin.env, or setbalda.telegram.tokenin config.no supported agent CLI detected: install or expose one ofcodex,opencode,copilot,gemini, orclaude.balda.provider is required: rerunbalda initor setbalda.providerto a configured provider ID.- Session history should not survive restarts: set
balda.sessions.persistence=memoryorBALDA_SESSIONS_PERSISTENCE=memory. - Memory facts are not visible in an active session: memory is snapshotted when a session starts or restores; close and reopen the session to refresh it.
- Workspace import/export issues: check
balda.workspace.mode,balda.workspace.base_branch, and that Balda is running in the expected git checkout. - Progress updates are too noisy: set
balda.telegram.plan_updates=false. - Startup fails while initializing the built-in runtime streams: keep the default
balda.natssettings unless you have a specific local runtime need, ensure${balda.state_dir}/natsis writable, and verify disk space. - Startup fails while initializing built-in runtime consumers: stop any other Balda process sharing the same embedded store and restart.
- Runtime issues show up in structured logs; check recent command failures and retry pressure before increasing transport limits.
zulip webhook disabled; skipping server start: setbalda.zulip.webhook.enabled=trueorBALDA_ZULIP_WEBHOOK_ENABLED=true.- Zulip webhook token mismatch: verify
balda.zulip.webhook_tokenmatches the token shown in the Zulip outgoing webhook bot settings. - Zulip 401 Unauthorized: check
balda.zulip.bot_emailandbalda.zulip.api_key. slack disabled; skipping server start: setbalda.slack.enabled=trueorBALDA_SLACK_ENABLED=true.- Slack request signature failures: verify
balda.slack.signing_secretmatches the app's signing secret and that the forwarding layer preserves request body bytes. - Slack delivery failures: check
balda.slack.bot_token, bot scopes, and whether Balda can reachhttps://slack.com/api.
- Technical specification:
docs/balda.md - Architecture map:
docs/architecture/index.md - Telegram formatting guide:
docs/telegram-formatting.md - Zulip webhook integration:
docs/zulip-webhook.md - Slack integration:
docs/slack.md - Contributing guide:
CONTRIBUTING.md - Agent workflow/policies:
AGENTS.md
- GitHub Releases: https://github.com/normahq/balda/releases
- npm package: https://www.npmjs.com/package/@normahq/balda