Consolidates listeningGroups and groups.requireMention into a single
groups config with explicit mode per group. Backward compatible --
legacy formats auto-normalize with deprecation warnings.
- Add shared group-mode.ts with isGroupAllowed/resolveGroupMode helpers
- Update all 5 channel adapters to use mode-based gating
- Default to mention-only for configured entries (safe), open when no config
- Listening mode now set at adapter level, bot.ts has legacy fallback
- Fix YAML large-ID parsing for groups map keys (Discord snowflakes)
- Add migration in normalizeAgents for listeningGroups + requireMention
- Add unit tests for group-mode helpers + update all gating tests
- Update docs, README, and example config
Closes#266
Written by Cameron and Letta Code
"Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away." -- Antoine de Saint-Exupery
* feat: default new configs to multi-agent format
Onboarding and non-interactive config generation now emit the
agents[] array format instead of the legacy agent/channels/features
flat structure. This makes adding a second agent a simple array
append rather than a config format migration.
Existing legacy configs continue to work -- normalizeAgents()
handles both formats at runtime.
Written by Cameron and Letta Code
"The future is already here -- it's just not very evenly distributed." -- William Gibson
* test: add save/load roundtrip tests for agents[] config format
Tests the actual YAML serialization path:
- agents[] written without legacy agent/channels at top level
- roundtrip through save + load + normalizeAgents preserves all fields
- global fields (providers, transcription) stay at top level
Written by Cameron and Letta Code
"Programs must be written for people to read, and only incidentally for machines to execute." -- Abelson & Sussman
* feat: eagerly create agent during onboarding
When the user picks "Create new agent", the agent is now created
immediately (with spinner) rather than deferred to first message.
The agent ID is written directly to lettabot.yaml, making the
config file the single source of truth.
Creates the agent with system prompt, memory blocks, selected model,
display name, skills, and disables tool approvals -- same setup that
bot.ts previously did lazily on first message.
Graceful fallback: if creation fails, falls back to lazy creation.
Written by Cameron and Letta Code
"Make it work, make it right, make it fast." -- Kent Beck
Tool call events between assistant messages were invisible in [Stream]
debug output, making it hard to understand message finalization timing.
- Add sawNonAssistantSinceLastUuid tracking to warn when assistant UUID
changes with no visible tool_call/reasoning events in between
- Replace [Bot] tool logging with [Stream]-prefixed structured summaries
(>>> TOOL CALL, <<< TOOL RESULT) for greppable output
- Bridge DEBUG=1 to DEBUG_SDK=1 so SDK-level dropped wire messages are
visible when debugging
Written by Cameron ◯ Letta Code
"The stream does not resist the rock; it flows around it, revealing its shape." -- Unknown
WhatsApp and Signal already had groups config with requireMention and
group allowlists. This brings the same pattern to the remaining three
channels, giving operators consistent control over which groups the bot
participates in and whether mentions are required.
- New `groups` config for Telegram, Discord, Slack (per-group allowlist
with requireMention, wildcard support)
- Telegram: standalone gating module with entity, text, command, and
regex mention detection; applied to text, voice, and attachment handlers
- Discord: inline gating using native message.mentions API
- Slack: gating in message handler (drops non-mentions) and app_mention
handler (allowlist check); helper methods on adapter
- Signal: pass wasMentioned through to InboundMessage (was detected but
never forwarded)
- Config wiring: groups/mentionPatterns forwarded from main.ts to all
adapter constructors
- 17 new tests for Telegram gating
Written by Cameron ◯ Letta Code
"Even in the group chat of life, sometimes you gotta be @mentioned
to know the universe is talking to you." — Ancient Internet Proverb
* Fix CLI group settings handling
* Document group settings in config
* fix: move group settings below channel list in README, add GROUP_DEBOUNCE_SEC env var
- README: group settings section was splitting the cross-channel bullet
list; moved it after the channel table
- onboard.ts: groupDebounceSec had no env var override for non-interactive
deploys; added <CHANNEL>_GROUP_DEBOUNCE_SEC for all 5 channels
- SKILL.md: documented the new env var
Written by Cameron and Letta Code
"For every complex problem there is an answer that is clear, simple, and wrong." - H.L. Mencken
---------
Co-authored-by: Jason Carreira <jason@visotrust.com>
Co-authored-by: Cameron <cameron@pfiffer.org>
Users running npm install get a dirty lockfile that blocks git pull.
Add an update script that handles the reset+pull+install+build cycle,
and document npm ci as the recommended install method.
Written by Cameron ◯ Letta Code
"Simplicity is the ultimate sophistication." -- Leonardo da Vinci
Add optional displayName field to agent config. When set, outbound
agent responses are prefixed (e.g. "💜 Signo: Hello!").
Useful in multi-agent group chats where multiple bots share a channel
and users need to tell them apart.
Closes#252
Written by Cameron ◯ Letta Code
"The details are not the details. They make the design." -- Charles Eames
* fix: prevent duplicate Telegram messages on "not modified" edit
When streaming completes and the final editMessage call sends identical
content, Telegram returns "message is not modified". The catch block
treated this as a real failure and bubbled up to bot.ts, which fell back
to sending the response as a brand-new message -- causing duplicates.
Now the Telegram adapter silently returns on "message is not modified"
since the content is already correct.
Written by Cameron ◯ Letta Code
"The simplest fix is the one that doesn't fight the API."
* fix: update package-lock.json for remark/mdast dependencies
PR #234 (Slack mrkdwn formatter) added remark-gfm and related mdast
packages but the lockfile wasn't regenerated, causing npm ci to fail
in CI with "Missing from lock file" errors.
Written by Cameron ◯ Letta Code
"The lockfile remembers what package.json forgets."
* Fix action directives and reactions
* revert package-lock.json to main
Drop unrelated lockfile churn from this PR -- the peer dependency
flag changes were artifacts of a different npm version.
Written by Cameron ◯ Letta Code
"The lockfile is a contract, not a suggestion." -- every CI pipeline ever
* docs: add response directives documentation
Document the XML action directives system (introduced in #239, parsing
fixes in #248): <actions> block format, <react> directive, attribute
quoting rules, channel support matrix, emoji alias tables, streaming
holdback behavior, and extension guide.
Written by Cameron ◯ Letta Code
"Documentation is a love letter to your future self." -- Damian Conway
---------
Co-authored-by: Jason Carreira <jason@visotrust.com>
Co-authored-by: Cameron <cameron@pfiffer.org>
Add `listeningGroups` config option (per-channel, same pattern as
`instantGroups`) where the bot periodically reads group messages and
sends them to the Letta agent for memory updates, but force-suppresses
response delivery unless the bot is explicitly mentioned.
- Add `isListeningMode` field to `InboundMessage`
- Add `listeningGroups` to all 5 channel configs
- Map `listeningGroups` to env vars in `configToEnv()`
- Rename `fixInstantGroupIds` to `fixLargeGroupIds` to also fix
`listeningGroups` entries (Discord snowflake precision fix)
- Build `listeningGroupIds` set and pass to bot
- Tag messages as listening mode in `processGroupBatch()` when group
is in listening mode and bot wasn't mentioned
- In `processMessage()`: skip typing indicator, heartbeat target
update, recovery error messages, and force-suppress response
delivery for listening mode messages
- Add `[OBSERVATION ONLY]` header to batch envelope for listening mode
- Document in lettabot.example.yaml
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
When an agent ID is configured but no conversation ID is stored, LettaBot
previously tried to resume the agent's default conversation via resumeSession().
If that default conversation was deleted or never created (e.g., after
migrations), the CLI exited with "Conversation default not found" and the bot
never replied to messages.
The fix changes getSession() to call createSession() instead of resumeSession()
when there's no stored conversation ID, ensuring a new conversation is always
created in this case.
👾 Generated with [Letta Code](https://letta.com)
Co-authored-by: Letta <noreply@letta.com>
* fix: recover from active runs stuck on approval
recoverOrphanedConversationApproval() only resolved approvals from
terminated (failed/cancelled) or abandoned (completed/requires_approval)
runs. Active runs with status=running and stop_reason=requires_approval
were skipped, even though they block the entire conversation.
This happens when the CLI's HITL approval flow leaves a pending approval
that no client will resolve (e.g. agent triggered via ADE, user walked
away). Every subsequent message fails with success=false.
Now also handles running+requires_approval: rejects the approval and
cancels the stuck run so lettabot can proceed.
Fixes the recovery path for all three call sites: pre-send check,
CONFLICT catch, and error-result retry.
Written by Cameron ◯ Letta Code
"The best error message is the one that never shows up." -- Thomas Fuchs
* fix: use cancelRuns return value for accurate diagnostics
Check boolean return from cancelRuns() instead of always logging
success. Details string now shows "(cancel failed)" when it didn't
work.
Written by Cameron ◯ Letta Code
"Trust, but verify." -- Ronald Reagan
* fix: detect terminal errors and retry with recovery in bot.ts
Monkey patch until SDK issue letta-code-sdk#31 is resolved.
1. Detect terminal errors (success=false OR error field), not just
empty-success. Both trigger orphan recovery + retry.
2. Blind retry on terminal error when no orphaned approvals found --
client-side approval failures may leave no detectable record.
3. sendToAgent() throws on terminal error instead of returning empty
string. Background callers get actionable errors.
Written by Cameron ◯ Letta Code
"A good patch is one you can remove." -- unknown
* test: add tests for approval recovery including stuck runs
7 tests covering recoverOrphanedConversationApproval():
- Empty conversation
- No unresolved approvals
- Recovery from failed run
- Recovery from stuck running+requires_approval (with cancel)
- Already-resolved approvals skipped
- Healthy running run not touched
- Cancel failure reported accurately
Written by Cameron ◯ Letta Code
"Code without tests is broken by design." -- Jacob Kaplan-Moss
* fix: gate retry on sentAnyMessage to prevent duplicate delivery
Codex review caught that finalizeMessage() clears the response buffer
on type changes, so hasResponse could be false even when output was
already sent. This caused duplicate retries with potential side effects.
Now checks !sentAnyMessage (authoritative delivery flag) in addition
to !hasResponse before retrying.
Written by Cameron ◯ Letta Code
"Idempotency: the art of doing nothing twice." -- unknown
* Slack: convert Markdown to mrkdwn
* Slack: avoid literal dynamic import for optional dep
* Slack formatter: cache optional dependency load state
* fix: remove slackify-markdown from lockfile dependencies
The lockfile had slackify-markdown in both `dependencies` (pinned) and
`optionalDependencies`, but package.json only lists it in
optionalDependencies. This caused npm ci to treat it as required,
defeating the optional dependency pattern.
Regenerated lockfile with clean npm install to fix.
Written by Cameron ◯ Letta Code
"The lockfile giveth, and the lockfile taketh away." - npm, probably
---------
Co-authored-by: Cameron <cameron@pfiffer.org>
Add multi-account Gmail polling with per-account seen tracking, updated
onboarding flow, and config/env resolution.
Based on jasoncarreira's work in #214, rebased onto current main and
cleaned up:
- parseGmailAccounts() extracted to polling/service.ts with 10 unit tests
- Per-account seen email tracking (Map<string, Set<string>>) with legacy
migration from single-account format
- Onboarding supports multi-select for existing accounts + add new
- Config resolution: polling.gmail.accounts > integrations.google.accounts
(legacy) > GMAIL_ACCOUNT env (comma-separated)
- GoogleAccountConfig type for per-account service selection
- Updated docs/configuration.md
Closes#214.
Written by Cameron ◯ Letta Code
"Good artists copy, great artists steal." - Pablo Picasso
* feat: add POST /api/v1/chat endpoint for sending messages to agents
Adds an HTTP endpoint that accepts a JSON message, sends it to the
lettabot agent via sendToAgent(), and returns the agent's response.
This enables external systems (e.g. server-side tools in other agents)
to communicate with lettabot programmatically.
- Add ChatRequest/ChatResponse types
- Add AgentRouter interface extending MessageDeliverer with sendToAgent()
- Implement AgentRouter on LettaGateway with agent-name routing
- Add POST /api/v1/chat route with auth, validation, and JSON body parsing
Written by Cameron ◯ Letta Code
"The most profound technologies are those that disappear." -- Mark Weiser
* feat: add SSE streaming support to /api/v1/chat endpoint
When the client sends Accept: text/event-stream, the chat endpoint
streams SDK messages as SSE events instead of waiting for the full
response. Each event is a JSON StreamMsg (assistant, tool_call,
tool_result, reasoning, result). The result event signals end-of-stream.
- Export StreamMsg type from bot.ts
- Add streamToAgent() to AgentSession interface and LettaBot
- Wire streamToAgent() through LettaGateway with agent-name routing
- Add SSE path in chat route (Accept header content negotiation)
- Handle client disconnect mid-stream gracefully
Written by Cameron ◯ Letta Code
"Any sufficiently advanced technology is indistinguishable from magic." -- Arthur C. Clarke
* test+docs: add chat endpoint tests and API documentation
- 10 tests for POST /api/v1/chat: auth, validation, sync response,
agent routing, SSE streaming, stream error handling
- 6 tests for gateway sendToAgent/streamToAgent routing
- Fix timingSafeEqual crash on mismatched key lengths (return 401, not 500)
- Document chat endpoint in configuration.md with sync and SSE examples
- Add Chat API link to docs/README.md index
Written by Cameron ◯ Letta Code
"First, solve the problem. Then, write the code." -- John Johnson
WhatsApp's "typing..." indicator lingers for 15-25 seconds after the bot
finishes responding because there was no way to clear it. This adds
stopTypingIndicator() which sends a "paused" presence update to
immediately dismiss it.
- stopTypingIndicator?() added to ChannelAdapter interface (optional)
- WhatsApp adapter implements it via sendPresenceUpdate("paused")
- bot.ts calls it in the finally block after stream processing
Written by Cameron ◯ Letta Code
"First, solve the problem. Then, write the code." - John Johnson
Agents can now include an <actions> block at the start of their text
response to perform actions without tool calls. The block is stripped
before the message is delivered to the user.
Example:
<actions>
<react emoji="thumbsup" />
</actions>
Great idea!
→ Sends "Great idea!", reacts with thumbsup
- New directives parser (src/core/directives.ts) finds <actions> block
at response start, parses self-closing child directives inside it
- addReaction() added to ChannelAdapter interface (Telegram, Slack,
WhatsApp already implement it)
- Streaming holdback covers the full <actions> block duration (prefix
check + incomplete block detection), preventing raw XML from flashing
- Directive execution extracted to executeDirectives() helper (no
duplication between finalizeMessage and final send paths)
- Message envelope includes Response Directives section so all agents
learn the feature regardless of system prompt
- System prompt documents the <actions> block syntax
- 19 unit tests for parser and stripping
Significantly cheaper than the Bash tool call approach (lettabot-react)
since no tool_call round trip is needed.
Relates to #19, #39, #240. Subsumes #210.
Written by Cameron ◯ Letta Code
"The best code is no code at all." - Jeff Atwood
Unit test job was discovering e2e/*.e2e.test.ts files and reporting
them as skipped, causing misleading "6 skipped" in PR checks.
Now test:run excludes e2e/ directory. E2E tests run separately via
test:e2e with secrets in CI.
Written by Cameron ◯ Letta Code
"Clean signals lead to confident decisions." -- unknown
* refactor: unify bot loop with runSession(), drop initialize/timeout
Unify the duplicated session lifecycle in processMessage() and
sendToAgent() into shared helpers:
- baseSessionOptions: computed once, not duplicated
- getSession(): 3-way create/resume/fallback in one place
- persistSessionState(): save agentId/conversationId/skills
- runSession(): send with CONFLICT retry, deduplicated stream
Also:
- Drop session.initialize() -- SDK auto-initializes on send()
- Drop withTimeout() wrapper -- SDK should own timeouts
- sendToAgent() shrinks from 98 to 20 lines
- processMessage() shrinks from 437 to ~250 lines (delivery stays)
- Net -187 lines (1013 -> 825)
All recovery logic preserved: pre-send attemptRecovery(),
CONFLICT catch + retry, empty-result orphan recovery.
Fixes#197
Written by Cameron ◯ Letta Code
"Make it work, make it right, make it fast." -- Kent Beck
* fix: narrow conversation-not-found fallback to 404/missing errors
Codex review caught that runSession() was retrying with createSession()
on ANY send error when agentId exists, not just conversation-missing
cases. Auth/network/protocol errors would incorrectly fork conversations.
Now only retries on 404 or error messages containing "not found" /
"missing" / "does not exist". Other errors propagate immediately.
Written by Cameron ◯ Letta Code
"Be conservative in what you send, be liberal in what you accept." -- Postel's Law
* fix: persist agent ID eagerly on creation, not deferred to result
Codex review caught that agent/conversation IDs were only saved in the
stream result handler. If createAgent() succeeded but send/stream failed,
the ID was lost and the next message would create a duplicate agent.
Now: getSession() persists the agent ID + runs first-run setup (name,
skills) immediately after createAgent(). persistSessionState() only
updates conversation ID on result.
Written by Cameron ◯ Letta Code
"Fail fast, but don't forget what you learned." -- unknown
* fix: persist conversation ID after send, before streaming
Codex review caught that conversationId was only saved on stream result.
If streaming disconnected or aborted before result, the next turn would
fall back to resumeSession(agentId) (default conversation) instead of
resuming the actual conversation -- forking context.
Now saved immediately after successful send(), matching the pre-refactor
behavior where it was saved after initialize().
Written by Cameron ◯ Letta Code
"The best time to save state was before the failure. The second best time is now." -- adapted proverb
* fix: switch group batching from fixed timer to 5-second debounce
The old 10-minute fixed timer caused groups to feel unresponsive after
inactivity. Now uses debounce: timer resets on every new message, flushes
after 5 seconds of quiet. @mentions still flush immediately.
New config: groupDebounceSec (default 5). Old groupPollIntervalMin still
works (converted to ms) for backward compatibility.
Fixes#229
Written by Cameron ◯ Letta Code
"The user is always right. If there is a problem with the use of the system, it's the system that's wrong, not the user." -- Don Norman
* docs: add group debounce configuration reference
Document groupDebounceSec, instantGroups, and debounce behavior
in the channel configuration section.
Written by Cameron ◯ Letta Code
"Good documentation is like a good joke: if you have to explain it, it's not that good." -- Kelsey Hightower
The old 10-minute fixed timer caused groups to feel unresponsive after
inactivity. Now uses debounce: timer resets on every new message, flushes
after 5 seconds of quiet. @mentions still flush immediately.
New config: groupDebounceSec (default 5). Old groupPollIntervalMin still
works (converted to ms) for backward compatibility.
Fixes#229
Written by Cameron ◯ Letta Code
"The user is always right. If there is a problem with the use of the system, it's the system that's wrong, not the user." -- Don Norman
- Replace raw JSON parsing with Store class (v1+v2 format support)
- Remove unused listModels import from model.ts
Written by Cameron ◯ Letta Code
"Simplicity is the ultimate sophistication." -- Leonardo da Vinci
- Keep multi-agent normalizeAgents() flow from main
- Integrate deprecation warning for agent.model from PR
- Remove model from LettaBot constructor (server-side property)
- Remove Model: display from single-agent startup log
Written by Cameron ◯ Letta Code
"The best interface is no interface." -- Golden Krishna
editMessage() had no fallback for MarkdownV2 failures (unlike sendMessage
which already falls back to plain text). When the agent generates markdown
tables or other complex formatting, the MarkdownV2 conversion can fail
mid-stream, silently leaving the user with whatever the last successful
streaming edit was -- a truncated message.
Three fixes:
- editMessage() now mirrors sendMessage's try/catch with plain-text fallback
- Final send retry no longer guarded by !messageId, so failed edits fall
back to sending a new complete message
- Streaming edit errors are logged instead of silently swallowed
Written by Cameron ◯ Letta Code
"If you want to go fast, go alone. If you want to go far, go together." - African Proverb
shared.ts was parsing lettabot-agent.json as v1 format directly,
returning null for v2 stores. Now uses the Store class which
handles v1/v2 transparently.
Affects lettabot-message, lettabot-react, and lettabot-history.
Written by Cameron ◯ Letta Code
"Simplicity is the ultimate sophistication." -- Leonardo da Vinci
* Add lettabot-history CLI
* Document and test lettabot-history
* Validate lettabot-history limit
* fix: address review feedback on history CLI
- Extract shared loadLastTarget into cli/shared.ts (was duplicated in message.ts, react.ts, history-core.ts)
- Clamp --limit to platform maximums (Discord: 100, Slack: 1000)
- Fix Discord author formatting: use globalName/username instead of deprecated discriminator
- Add Slack fetch test
Written by Cameron ◯ Letta Code
"You miss 100% of the shots you don't take." -- Wayne Gretzky -- Michael Scott
---------
Co-authored-by: Jason Carreira <jason@visotrust.com>
Co-authored-by: Cameron <cameron@pfiffer.org>
* feat: custom heartbeat prompt via YAML config or file
Wire up the existing but unused HeartbeatConfig.prompt field so users
can customize what the agent sees during heartbeats. Adds three ways
to set it: inline YAML (prompt), file-based (promptFile, re-read each
tick for live editing), and env var (HEARTBEAT_PROMPT). Also documents
the <no-reply/> opt-out behavior.
Fixes#232
Written by Cameron ◯ Letta Code
"The only way to do great work is to love what you do." -- Steve Jobs
* test: add coverage for heartbeat prompt resolution
Tests buildCustomHeartbeatPrompt and HeartbeatService prompt resolution:
- default prompt fallback
- inline prompt usage
- promptFile loading
- inline > promptFile precedence
- live reload (file re-read each tick)
- graceful fallback on missing file
- empty file falls back to default
Written by Cameron ◯ Letta Code
"The only way to do great work is to love what you do." -- Steve Jobs
- Version 0.2.0 (was 1.0.0 -- too early for stable)
- Add files field: only ship dist/, .skills/, patches/
- Add engines: node >= 20
- Add repository, homepage, author metadata
- Add prepublishOnly: build + test gate
- Move patch-package from postinstall to prepare (don't run for end users)
- Add npm publish step to release workflow (requires NPM_TOKEN secret)
- Pre-releases publish with --tag next, stable with --tag latest
- Update release notes install instructions for npm
Closes#174 (once NPM_TOKEN is configured)
Written by Cameron ◯ Letta Code
"Shipping is a feature." -- Jez Humble
normalizeAgents() env var fallback (added in #224) only mapped token
and dmPolicy, missing allowedUsers for all channels. This caused
dmPolicy=allowlist to reject everyone since the allowlist was empty.
Parses *_ALLOWED_USERS env vars (comma-separated) for all five
channels, matching the existing format used by onboard.ts and the
Railway env exporter.
Written by Cameron ◯ Letta Code
"We can only see a short distance ahead, but we can see plenty there that needs to be done." -- Alan Turing
normalizeAgents() only read YAML config, breaking Railway/Docker
deploys where channels are configured via environment variables
(TELEGRAM_BOT_TOKEN, etc.). Add env var fallback in the legacy
single-agent path.
Multi-agent mode still requires YAML configuration.
Written by Cameron ◯ Letta Code
"The cheapest, fastest, and most reliable components are those that aren't there." -- Gordon Bell
* docs: add multi-agent configuration reference
Document the agents[] YAML config, per-agent options, migration path
from single to multi-agent, and known limitations (#219, #220, #221).
Written by Cameron ◯ Letta Code
"Documentation is a love letter that you write to your future self." -- Damian Conway
* docs: fix channels required claim and soften isolation wording
- channels is not strictly required per-agent (validation is global)
- isolation has known exceptions, don't claim "fully isolated"
Written by Cameron ◯ Letta Code
"Clear is kind." -- Brene Brown
When the agent sends a short single-chunk response (e.g. "Yep."), the
streaming edit path sends it to the channel immediately but never sets
sentAnyMessage. When finalizeMessage() then tries to edit the message
to identical content, Telegram rejects it ("message not modified"),
the catch swallows the error, and the post-loop fallback shows a
spurious "(No response)" error alongside the actual reply.
Fix: (1) set sentAnyMessage when the streaming path sends a new message,
(2) treat edit failures as success when messageId exists (the message
is already displayed to the user).
Written by Cameron ◯ Letta Code
"The stream carried everything -- we just forgot to look." - on debugging
Two bugs from the Phase 1 merge:
1. Store not scoped to agent name: LettaBot constructor passed no agent
name to Store, so all agents in multi-agent mode shared the same
agentId/conversationId state. Now passes config.agentName.
2. agentConfig.id ignored: Users could set `id: agent-abc123` in YAML
but it was never applied. Now checked before store verification.
Written by Cameron ◯ Letta Code
"The best error message is the one that never shows up." -- Thomas Fuchs
Multi-agent configs now work end-to-end. Users can write `agents:` in
lettabot.yaml and each agent gets its own channels, cron, heartbeat,
and polling services.
- Rewrite main() to loop over normalizeAgents() output
- Extract createChannelsForAgent() and createGroupBatcher() helpers
- Replace env-var config parsing with YAML-direct for per-agent config
- LettaGateway manages all agents, API server uses gateway for delivery
- Per-agent services: cron, heartbeat, polling are agent-scoped
- Store v2 format handled at startup for LETTA_AGENT_ID loading
- Legacy single-agent configs work unchanged via normalizeAgents()
Part of #109
Written by Cameron ◯ Letta Code
"Make it work, make it right, make it fast." -- Kent Beck
Interface-first multi-agent orchestration layer.
- Define AgentSession interface capturing the contract consumers depend on
- LettaBot implements AgentSession (already has all methods, now explicit)
- LettaGateway manages multiple named AgentSession instances
- Update heartbeat, cron, polling, API server to depend on interface, not concrete class
- 8 new gateway tests
No behavioral changes. Consumers that used LettaBot now use AgentSession interface,
enabling multi-agent without modifying consumer code.
Part of #109
Written by Cameron ◯ Letta Code
"First, solve the problem. Then, write the code." -- John Johnson
* feat: add multi-agent config types, normalizeAgents, and Store v2
Foundation for multi-agent support (Phase 1a). No behavioral changes.
- Add AgentConfig interface for per-agent configuration
- Add agents[] field to LettaBotConfig for docker-compose style multi-agent configs
- Add normalizeAgents() to convert legacy single-agent config to agents[] array
- Evolve Store to v2 format with per-agent state isolation
- Auto-migrate v1 store files to v2 transparently
- 18 new tests for normalization and store migration
Part of #109
Written by Cameron ◯ Letta Code
"The only way to do great work is to love what you do." -- Steve Jobs
* fix: harden multi-agent normalization and store isolation
* style: simplify redundant WhatsApp enabled check
WhatsApp has no credential to check (uses QR pairing), so the
`enabled !== false && enabled` condition simplifies to just `enabled`.
Written by Cameron ◯ Letta Code
"Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away." -- Antoine de Saint-Exupery
feat: pass images to the LLM via multimodal API
When users send images through any channel, the actual image content is now passed to the LLM via the SDK's multimodal API (imageFromFile/imageFromURL) instead of just text metadata.
- Graceful fallback for unsupported MIME types, missing files, and load errors
- Opt-out via features.inlineImages: false in config
- Warns when model doesn't support vision (detects [Image omitted] in response)
- Warn at startup when agent.model is present in lettabot.yaml (deprecated, now ignored)
- Add e2e tests for model listing and agent model retrieval
- Remove stale model field from existing e2e test (BotConfig no longer has it)
Written by Cameron ◯ Letta Code
"The best way to predict the future is to invent it." -- Alan Kay
- Document api.port/host/corsOrigin in configuration.md (example,
reference table, and env var mapping)
- Add "Long Messages" section to telegram-setup.md noting the
automatic 4096-char splitting behavior
Written by Cameron ◯ Letta Code
"The best time to plant a tree was 20 years ago. The second best time is now." - Chinese Proverb
Voice messages are now saved to the attachments directory regardless of
transcription outcome. The audio file path is included in the message
envelope so agents always have access to the original audio, even when
transcription fails or returns empty.
🐾 Generated with [Letta Code](https://letta.com)
Co-authored-by: Letta <noreply@letta.com>
* feat: add group message batching, Telegram group gating, and instantGroups
Group Message Batcher:
- New GroupBatcher buffers group chat messages and flushes on timer or @mention
- Channel-agnostic: works with any ChannelAdapter
- Configurable per-channel via groupPollIntervalMin (default: 10min, 0 = immediate)
- formatGroupBatchEnvelope formats batched messages as chat logs for the agent
- Single-message batches unwrapped to use DM-style formatMessageEnvelope
Telegram Group Gating:
- my_chat_member handler: bot leaves groups when added by unpaired users
- Groups added by paired users are auto-approved via group-store
- Group messages bypass DM pairing (middleware skips group/supergroup chats)
- Mention detection for @bot in group messages
Channel Group Support:
- All adapters: getDmPolicy() interface method
- Discord: serverId (guildId), wasMentioned, pairing bypass for guilds
- Signal: group messages bypass pairing
- Slack: wasMentioned field on messages
instantGroups Config:
- Per-channel instantGroups config to bypass batching for specific groups
- For Discord, checked against both serverId and chatId
- YAML config → env vars → parsed in main.ts → Set passed to bot
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: preserve large numeric IDs in instantGroups YAML config
Discord snowflake IDs exceed Number.MAX_SAFE_INTEGER, so YAML parses
unquoted IDs as lossy JavaScript numbers. Use the document AST to
extract the original string representation and avoid precision loss.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: Slack dmPolicy, Telegram group gating check
- Add dmPolicy to SlackConfig and wire through config/env/adapter
(was hardcoded to 'open', now reads from config like other adapters)
- Check isGroupApproved() in Telegram middleware before processing
group messages (approveGroup was called but never checked)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The watchdog aborted streams after idle timeouts, which breaks legitimate
subagent operations. The SDK stream should throw on connection failures.
Written by Cameron ◯ Letta Code
"The stream will end when it's ready." - a patient engineer
Add a top-level `polling` section to lettabot.yaml for configuring
background polling (Gmail, etc.) instead of relying solely on env vars.
- Add `PollingYamlConfig` type with `enabled`, `intervalMs`, and `gmail` subsection
- Update `configToEnv()` to map new polling config to env vars
- Update `main.ts` to read from YAML config with env var fallback
- Maintain backward compat with `integrations.google` legacy path
- Document polling config in docs/configuration.md
Fixes#201
Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com>
Co-authored-by: Cameron <cpfiffer@users.noreply.github.com>