Prototype. A Concorde deployment that lets Shutter keyper operators talk to one agent over Telegram, with every message recorded per user in the gateway. Runs on one droplet. No operator is connected yet; the only user is the person testing it.
Built from examples/00_minimal of shutter-network/concorde at commit e3f746e, with:
telegram-channel/, a Channel for the Telegram Bot API, written against the Messenger's Channel contract and modelled on the framework's Nostr Channel. It replaces the HTTP Channel. Meant to move into Concorde as@shutter-network/concorde/telegram-channelonce it has run.main.tsforwards whichever provider keys are set instead of requiring the Anthropic one, mountsmodels.jsoninto the agent container, and attaches the tester's chat to the seeded user at boot.settings.jsonandmodels.jsonpoint pi at an OpenAI-compatible endpoint of your choice. Both are per machine, copied from their.examplefiles and never committed.AGENTS.mdadds the Grafana dashboard instructions.
@shutter-network/concorde is not on npm yet (issue 01 on the Concorde review). Until it is,
the image installs it from a tarball in vendor/ that is never committed: .gitignore excludes
it, and each machine that builds the image produces its own.
cd path/to/concorde && git checkout e3f746e && npm ci && npm run build && npm pack
mv shutter-network-concorde-0.1.0.tgz path/to/this/repo/vendor/When the package is published, package.json goes back to "^0.1.0" and vendor/ is deleted.
- Put the tarball in
vendor/as above. cp .env.example .envand fill inLOCAL_API_KEY,USER_PASSWORD,TG_TOKEN,TG_CHAT.cp models.json.example models.jsonandcp settings.json.example settings.json, then put the endpoint URL and the model id in both. The key stays in.env;models.jsonrefers to it as$LOCAL_API_KEY.- If another process polls the same bot token, stop it first. Telegram hands each update to one poller, and the Channel logs a 409 until the other one is gone.
docker compose up -d --build
docker compose logs -f gatewayExpected at boot: user <id> logs in with the password ..., then
telegram chat <TG_CHAT> now belongs to user <id>. Message the bot; the reply comes back in
the same chat.
A message from a chat that belongs to no user gets one answer, its own chat id, and nothing is
recorded. That is how a new operator learns the id to send the team. admin.ts does the rest,
against the database, with or without the gateway running:
docker compose run --rm --no-deps gateway node admin.ts list
docker compose run --rm --no-deps gateway node admin.ts add <name> <chatId>
docker compose run --rm --no-deps gateway node admin.ts detach <userId>add creates the user, names them and attaches the chat in one transaction. detach removes
the chat only: the framework removes no user, and the message log stays. A group chat works as
a user too; its id is negative.
telegram-channel/telegram-channel.test.ts runs the Channel against a real PostgreSQL and a
fake Bot API on localhost (fake-bot-api.ts): recording chats, inbound texts and redelivery,
unknown chats, outbound replies, refusals, transient failures, splitting, a reply queued while
stopped, a 409 from a second poller, stop and start. The helpers in test-support.ts mirror
the framework's own.
Against the stack's database, without starting the gateway:
docker compose run --rm testOr anywhere with Node 24 and a PostgreSQL to create databases on:
DATABASE_URL=postgres://user:password@host:5432/postgres npm test-
The model is chosen in
settings.jsonand described inmodels.json. After changing the model, deletestate/agent/sessions/*: pi sessions pin the model they started with../switch-model.sh <model-id>does both in one step. -
A run that fails tells the sender "I could not process your last message" through the handler's
postphase, and nothing more. The reason is indocker compose logs gateway, as the error on theRun finishedline, and in the agent container's own log while it runs:docker logs $(docker ps -q --filter ancestor=keyper-concorde-agent:0.83.0). -
If the model endpoint is down, every message looks like the agent is offline. Test the endpoint directly with a one-word chat completion before debugging anything here, with
BASE_URL,API_KEYandMODELset to the values frommodels.jsonandsettings.json:curl -sS -m 60 -w '\nHTTP %{http_code} in %{time_total}s\n' \ "$BASE_URL/chat/completions" \ -H "authorization: Bearer $API_KEY" \ -H "content-type: application/json" \ -d "{\"model\":\"$MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with the single word OK.\"}],\"max_tokens\":50}"
A healthy endpoint answers within seconds with
HTTP 200. A timeout orHTTP 000means the box behind the proxy is down, and nothing in this repo can fix that. -
docker compose downkeeps the database.down -vdeletes it, including the user, its chat and the whole message log.
| file | role |
|---|---|
main.ts |
the deployment: runtime, components, handler, seeding |
telegram-channel/ |
the Telegram Channel: schema, chats, outbox, Bot API, channel |
compose.yml |
gateway, migrate, postgres, agent image |
AGENTS.md |
the agent's instructions, mounted read-only |
settings.json, models.json |
pi's model configuration, mounted read-only |
schema.ts, drizzle.config.ts |
tables the deployment applies with drizzle-kit |