Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 35 additions & 14 deletions src/channels/README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
# Channel plugins

Letta Code channels connect agents to external chat systems. Telegram, Slack,
and Discord are first-party bundled plugins with custom Desktop UI. User-defined
plugins are loaded from `~/.letta/channels/<channel-id>/` and run headlessly:
they can receive inbound messages, participate in pairing/routing, and extend
the shared `MessageChannel` tool, but they do not get custom Desktop screens.
Letta Code channels connect agents to external chat systems. First-party
channels may have bespoke account models and Desktop UI. Experimental bundled
channels use the generic plugin account model but ship with Letta Code.
User-defined plugins are loaded from `~/.letta/channels/<channel-id>/` and run
headlessly: they can receive inbound messages, participate in pairing/routing,
and extend the shared `MessageChannel` tool, but they do not get custom Desktop
screens.

## Directory layout

Expand Down Expand Up @@ -119,19 +121,20 @@ fields internally, but user plugins should only rely on `account.config`.

## Runtime behavior

The MVP runtime path supports custom plugins that fit the generic pairing and
routing flow:
The plugin runtime follows one central access and routing path:

1. The adapter receives an inbound message and calls `adapter.onMessage(msg)`.
2. Letta Code enforces `dmPolicy` / `allowedUsers`.
3. Letta Code resolves a route from `routing.yaml` or creates a pairing code.
4. The routed message is delivered to the bound agent/conversation.
5. `MessageChannel` becomes available when the conversation has an active route
for at least one running channel adapter.
3. Letta Code resolves an existing route. An adapter may implement
`resolveAutoRoute(msg)` to select an agent when no route exists; Letta Code
then creates the conversation and persists the route centrally.
4. Messages without an existing or automatic route use the pairing flow.
5. The routed message is delivered to the bound agent/conversation.
6. `MessageChannel` becomes available while that route has a running adapter.

Plugins that need Slack/Discord-style auto-routing or rich Desktop management
remain first-party/bundled work for now. Custom plugins can still expose custom
`MessageChannel` actions and schema fragments via `messageActions`.
Custom plugins can expose channel-specific `MessageChannel` actions and schema
fragments via `messageActions`. Bespoke rich Desktop management remains
first-party work.

> Note: inbound channel delivery and user-visible replies are separate steps.
> A channel message can successfully reach the agent, but the agent still has to
Expand All @@ -142,6 +145,24 @@ remain first-party/bundled work for now. Custom plugins can still expose custom
> called `MessageChannel`, whether the tool result says the message was sent,
> and whether the route/account IDs match the original chat.

## Linear (experimental)

The bundled Linear channel polls one Linear account's notification inbox and
maps each issue to one persistent Letta conversation. It posts agent replies as
Linear comments and ignores its own comments to prevent reply loops.

```bash
letta channels configure linear
letta server --channels linear
```

Setup asks for a Linear personal API key and the Letta agent that should own new
issue conversations. The key uses the normal channel credential store; with
Keychain enabled it is not retained in plaintext in `accounts.json`.

This initial channel uses polling and personal API keys. Linear OAuth, webhooks,
and bespoke Desktop UI are outside the experimental surface.

## Local backend channels

Channels can run against the experimental local backend without registering a
Expand Down
79 changes: 76 additions & 3 deletions src/channels/gateway-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,13 @@ function makeHooks(
const externalToolResults: ExternalToolCallResult[] = [];

const hooks: ChannelGatewayHooks = {
buildExternalTool: async () =>
({
buildExternalTools: async () => [
{
name: "MessageChannel",
description: "Send a message through a channel",
parameters: {},
}) satisfies ExternalToolDefinitionPayload,
} satisfies ExternalToolDefinitionPayload,
],
executeExternalTool: async (_request) => {
const result: ExternalToolCallResult = {
content: [{ type: "text", text: "ok" }],
Expand Down Expand Up @@ -583,6 +584,78 @@ test("runtime registration happens before input submission", async () => {
gateway.close();
});

test("runtime registration clears gateway tools when none remain eligible", async () => {
const client = new FakeClient();
let eligible = true;
const { hooks } = makeHooks({
buildExternalTools: async () =>
eligible
? [
{
name: "MessageChannel",
description: "Send a message through a channel",
parameters: {},
},
]
: [],
});
const gateway = new ChannelGateway(client, hooks);

await gateway.registerRuntime(TEST_RUNTIME, [makeSource()]);
eligible = false;
await gateway.registerRuntime(TEST_RUNTIME, []);

expect(client.startedRuntimes).toHaveLength(2);
expect(client.startedRuntimes[0]?.external_tools).toEqual([
expect.objectContaining({ scope_id: "channel-gateway" }),
]);
expect(client.startedRuntimes[1]?.external_tools).toEqual([]);
gateway.close();
});

test("runtime registration serializes capability refreshes in call order", async () => {
const client = new FakeClient();
let releaseFirst!: () => void;
let markFirstStarted!: () => void;
const firstGate = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const firstStarted = new Promise<void>((resolve) => {
markFirstStarted = resolve;
});
let callCount = 0;
const { hooks } = makeHooks({
buildExternalTools: async () => {
callCount += 1;
if (callCount === 1) {
markFirstStarted();
await firstGate;
return [
{
name: "MessageChannel",
description: "Send a message through a channel",
parameters: {},
},
];
}
return [];
},
});
const gateway = new ChannelGateway(client, hooks);

const first = gateway.registerRuntime(TEST_RUNTIME, [makeSource()]);
await firstStarted;
const second = gateway.registerRuntime(TEST_RUNTIME, []);
releaseFirst();
await Promise.all([first, second]);

expect(client.startedRuntimes.map((entry) => entry.external_tools)).toEqual([
[expect.objectContaining({ scope_id: "channel-gateway" })],
[],
]);
gateway.close();
});

test("runtime registration is skipped when signature matches", async () => {
const client = new FakeClient();
const { hooks } = makeHooks();
Expand Down
42 changes: 28 additions & 14 deletions src/channels/gateway-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ import type {
export const CHANNEL_GATEWAY_TOOL_SCOPE_ID = "channel-gateway";
const MAX_ACCEPTED_CLIENT_MESSAGE_IDS = 2048;

type RuntimeExternalTools = NonNullable<RuntimeStartCommand["external_tools"]>;

function groupGatewayExternalTools(
tools: ExternalToolDefinitionPayload[],
): RuntimeExternalTools {
if (tools.length === 0) return [];
return [{ scope_id: CHANNEL_GATEWAY_TOOL_SCOPE_ID, tools }];
}

export interface ChannelGatewayClient {
close(): void;
onMessage(listener: (message: WsProtocolMessage) => void): () => void;
Expand Down Expand Up @@ -55,10 +64,9 @@ export interface ChannelGatewayDelivery {
}

export interface ChannelGatewayHooks {
buildExternalTool(
buildExternalTools(
runtime: RuntimeScope,
sources: ChannelTurnSource[],
): Promise<ExternalToolDefinitionPayload>;
): Promise<ExternalToolDefinitionPayload[]>;
executeExternalTool(
request: ExternalToolCallRequestMessage,
sources: ChannelTurnSource[],
Expand Down Expand Up @@ -106,6 +114,7 @@ type GatewayRuntimeState = {
active: ActiveGatewayTurn | null;
registrationSignature: string | null;
registration: Promise<void> | null;
registrationQueue: Promise<void>;
replayedControlRequestIds: Set<string>;
submissionQueue: Promise<void>;
hookQueue: Promise<void> | null;
Expand Down Expand Up @@ -351,6 +360,7 @@ export class ChannelGateway {
active: null,
registrationSignature: null,
registration: null,
registrationQueue: Promise.resolve(),
replayedControlRequestIds: new Set(),
submissionQueue: Promise.resolve(),
hookQueue: null,
Expand Down Expand Up @@ -387,17 +397,26 @@ export class ChannelGateway {
return pending;
}

private async ensureRuntimeRegistration(
private ensureRuntimeRegistration(
state: GatewayRuntimeState,
delivery: ChannelGatewayDelivery,
): Promise<void> {
const tool = await this.hooks.buildExternalTool(
delivery.runtime,
delivery.sources,
const registration = state.registrationQueue.then(() =>
this.performRuntimeRegistration(state, delivery),
);
state.registrationQueue = registration.catch(() => undefined);
return registration;
}

private async performRuntimeRegistration(
state: GatewayRuntimeState,
delivery: ChannelGatewayDelivery,
): Promise<void> {
const tools = await this.hooks.buildExternalTools(delivery.runtime);
const externalTools = groupGatewayExternalTools(tools);
const signature = JSON.stringify({
mode: delivery.defaultPermissionMode ?? null,
tool,
externalTools,
});
if (state.registrationSignature === signature && state.registration) {
return state.registration;
Expand All @@ -414,12 +433,7 @@ export class ChannelGateway {
force_device_status: false,
wait_for_replay: true,
client_info: { name: "channel-gateway", title: "Channel Gateway" },
external_tools: [
{
scope_id: CHANNEL_GATEWAY_TOOL_SCOPE_ID,
tools: [tool],
},
],
external_tools: externalTools,
})
.then((response) => {
if (!response.success) {
Expand Down
39 changes: 7 additions & 32 deletions src/channels/gateway-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
buildChannelModelUpdateFailedMessage,
} from "./commands";
import { ChannelGateway, type ChannelGatewayDelivery } from "./gateway-core";
import { buildDynamicMessageChannelToolDefinition } from "./message-tool";
import { buildChannelGatewayExternalTools } from "./gateway-tools";
import {
type ChannelsCommand,
handleChannelsProtocolCommand,
Expand Down Expand Up @@ -214,37 +214,12 @@ export async function startLocalChannelGateway(
}
: null;
},
buildExternalTool: async (runtime, deliverySources) => {
const routeSources = registry.resolveTurnSourcesForScope(
runtime.agent_id,
runtime.conversation_id,
);
const sources = [...routeSources, ...deliverySources];
const seen = new Set<string>();
const channels = sources
.map((source) => ({
channelId: source.channel,
accountId: source.accountId ?? null,
}))
.filter(({ channelId, accountId }) => {
const key = `${channelId}:${accountId ?? ""}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
const base = TOOL_DEFINITIONS.MessageChannel;
const resolved = await buildDynamicMessageChannelToolDefinition(
base.description,
base.schema,
{ channels },
);
return {
name: "MessageChannel",
label: "Message Channel",
description: resolved.description,
parameters: resolved.schema,
};
},
buildExternalTools: (runtime) =>
buildChannelGatewayExternalTools(
registry,
runtime,
TOOL_DEFINITIONS.MessageChannel,
),
executeExternalTool: async (request, sources) => {
if (request.tool_name !== "MessageChannel" || !request.runtime) {
throw new Error(`Unsupported gateway tool: ${request.tool_name}`);
Expand Down
35 changes: 35 additions & 0 deletions src/channels/gateway-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { expect, test } from "bun:test";
import { buildChannelGatewayExternalTools } from "./gateway-tools";
import type { ChannelTurnSource } from "./types";

const runtime = { agent_id: "agent-1", conversation_id: "conv-1" };
const baseTool = {
description: "Send a message through a channel",
schema: { type: "object", properties: {} },
};
const source: ChannelTurnSource = {
channel: "telegram",
accountId: "telegram-default",
chatId: "chat-1",
agentId: "agent-1",
conversationId: "conv-1",
};

test("gateway tool resolution follows routed source eligibility", async () => {
let sources = [source];
const registry = {
resolveTurnSourcesForScope: () => sources,
};

const eligible = await buildChannelGatewayExternalTools(
registry,
runtime,
baseTool,
);
expect(eligible.map((tool) => tool.name)).toEqual(["MessageChannel"]);

sources = [];
await expect(
buildChannelGatewayExternalTools(registry, runtime, baseTool),
).resolves.toEqual([]);
});
Loading
Loading