Appearance
Human channels
VeloxSaarthi talks to its human operator through a channel — the place where the factory posts story updates, asks clarification questions, requests plan approvals, and accepts operator commands. The channel is pluggable: you pick one in config, and the rest of the system is unaware of which messenger is behind it.
Selecting a channel
yaml
adapters:
human_channel: "telegram" # "telegram" | "teams" | "web"Each channel reads its own config block (telegram:, teams:) — see Configure. The daemon instantiates exactly one channel at startup.
The common interface
Every channel implements a single contract, HumanChannelPort (src/core/ports/human-channel.ts). Nothing in the orchestrator, state machine, or notifier knows about Telegram or Teams — they only call this port:
| Method | Purpose |
|---|---|
ensureStoryThread(story) | Create/return the per-story thread, return a ThreadRef |
postUpdate(thread, update) | Post a stage/lifecycle status message |
postQuestion(thread, q) | Ask a clarification question (optionally with options) |
postApprovalRequest(thread, req) | Post a non-blocking plan-approval request |
requestApproval(thread, req) | Blocking approval (sensitive-tool permission path) |
editUpdate?(thread, ref, update) | Edit a previously posted message in place |
postMedia?(thread, media) | Attach a photo/video/document |
registerApprovalWaiter?(cid) | Re-attach a blocking waiter after a restart |
Inbound events (operator replies, approvals, slash commands) are delivered back to the daemon through a small mutable deps object per channel (telegramDeps, teamsDeps), which the daemon late-binds with DB-backed callbacks at startup.
Capability differences between channels
The port is identical, but the underlying messengers are not. Where a messenger cannot do something the way Telegram does, the adapter degrades gracefully — this table is the source of truth for what to expect on each channel.
| Capability | Telegram | Microsoft Teams | Web (local) |
|---|---|---|---|
| Per-story thread | ✅ forum topic | ✅ channel root message | ✅ dashboard panel |
| Status updates | ✅ | ✅ | ✅ |
| In-place message edit | ✅ | ⚠️ best-effort (Graph PATCH often 403 → posts a follow-up reply) | ✅ |
| Clarification question | ✅ | ✅ | ✅ |
| Option picker | ✅ tap an inline button | ⚠️ type the option number/text (Adaptive Card buttons are visual only) | ✅ click |
| Plan approval | ✅ Approve/Reject buttons | ⚠️ type "approve" / "reject" | ✅ click |
| Approval artifacts (spec/plan) | ✅ uploaded as files | ⚠️ linked as text (no ad-hoc upload without SharePoint) | ✅ |
| Media (photo/video/doc) | ✅ | ❌ not supported (Graph limitation) | ✅ |
| Slash commands | ✅ /respin /requeue /cancel /history /restart /vetomerge | ⚠️ same, typed as text (no autocomplete registration) | n/a |
| Inbound transport | long-poll (getUpdates) | interval poll (default 5 s) | in-process |
| Decision latency | ~instant | up to poll_interval_ms (default 5 s) | instant |
| Authorization | numeric user-id allowlist | AAD object-id allowlist | local-only |
Legend: ✅ full parity · ⚠️ available but works differently · ❌ unavailable.
Why Teams differs
These are inherent to the Microsoft Graph API, not gaps in the adapter:
- No long-poll. Graph has no
getUpdatesequivalent, so the Teams adapter polls each story thread on an interval. A webhook (Bot Framework) build would remove the latency but requires a public HTTPS endpoint — a deployment cost. - No inline-button callbacks. Without a Bot Framework endpoint, button taps produce no event. Approvals and option answers are therefore made by typing in the thread; an Adaptive Card is shown for affordance only.
- Edits are best-effort. Patching a message you don't own typically returns 403 in app context, so the adapter falls back to posting a follow-up reply.
Adding a new channel (e.g. Slack)
The seam is small. To add a channel:
- Implement
HumanChannelPortinsrc/adapters/<channel>/channel.ts(outbound posting + an inbound poller/handler that calls the deps callbacks). - Export a
<channel>Depsholder (mirrorsrc/adapters/telegram/index.ts). - Register a factory in
src/bootstrap/adapter-registry.tskeyed by the config string. - Add the channel's config block to the zod schema in
src/config/schema.ts. - Wire the daemon deps for the channel (see
src/daemon/*-commands.ts). - Add a column to the table above documenting any capability differences.
Because the orchestrator only ever sees HumanChannelPort, no other code changes when a new channel is added — selection is a single config string.