Chat Connectors
Expose an agent folder through Chat SDK platforms or a rich web chat route.
Little Harness: published on npm as little-harness@0.2.0-alpha.0 (2026-10-05). Sections labeled UNRELEASED describe source-only changes. Release status
Chat connectors live under an agent folder's connectors/ directory. They are connection layers, not
model capabilities: loadHarness() still loads only the agent, tools, skills, and instructions.
Layout
agents/support/
agent.ts
tools/
add-reaction.ts
skills/
connectors/
slack/
connector.ts
tools/
add-reaction.ts
web-rich/
connector.ts
renderers/
lookup-order.tsx
ui/
types.ts
tool-parts/Flat files such as connectors/slack.ts still work for simple descriptors, and a flat file can still
carry a sibling connectors/slack/tools/ directory. Use a nested folder such as
connectors/slack/connector.ts when the connector also has tool extensions, renderers, or delivery
helpers. A nested connector.* module that fails to import is a hard error (Failed to load connector module.); a flat helper file that is not a connector descriptor is skipped, not fatal.
The connectors/web-rich/renderers/ folder is a naming convention only. Little Harness does not load
or execute it — put presentation code wherever you like and wire it up in your own client. See
Typed Tool UI.
Which Import Path?
All connector APIs live under little-harness/connectors*. Both /connectors and /connectors/runtime
statically reach node:fs/promises (through the shared streamHarness → skill-staging code), so a
value import from either subpath is server-only — neither is bundle-safe for the browser. A
types-only import (import type { ... }) from any subpath is always safe, because types are erased
at build time. Pick the subpath by which helpers you need, not by hoping to avoid node:fs:
| Import path | Contains | Use it for |
|---|---|---|
@little-stack/harness/connectors/runtime | Descriptors, loaders, session registry, tool-context helpers, tool-UI types — everything except the discovery helpers | Server route bundles. Omitting the discovery helpers means a bundler never statically sees the connector-module dynamic-import machinery, so it does not try to trace your connector.*/tool files through this subpath. |
@little-stack/harness/connectors | Everything above plus discoverConnectors / loadConnectorDescriptor / loadConnectorToolExtensions | Build scripts, tests, or CLIs that enumerate connectors. A value import statically pulls the discovery module (and its connector-module traversal) into the graph. |
@little-stack/harness/connectors/discovery | Just the discovery helpers | Explicitly importing filesystem discovery without the rest of the surface. |
The practical difference between the two main subpaths is the discovery surface, not node:fs: only
/connectors statically links the filesystem connector-discovery helpers, so only it makes a bundler
attempt to trace connector-module dynamic imports. String connector references (connector: "slack")
work from every path — the loaders import discovery dynamically at call time, so a /runtime bundle
never statically links the discovery module even when you pass a connector id. The tool-context helpers
(harnessToolContext, tryHarnessToolContext) and the tool-UI types (ToolUI, ToolOutputPart) are
exported from both /connectors and /connectors/runtime.
Chat SDK Platforms
Use chatSdkConnector(...) for Slack, Telegram, Discord, Teams, WhatsApp, and other Chat SDK
adapters. Install chat and the adapter packages in the app that hosts the route.
// agents/support/connectors/slack/connector.ts
import { createSlackAdapter } from "@chat-adapter/slack";
import { createRedisState } from "@chat-adapter/state-redis";
import { chatSdkConnector } from "@little-stack/harness/connectors/runtime";
export default chatSdkConnector({
userName: "support",
adapter: { name: "slack", create: () => createSlackAdapter() },
state: () => createRedisState(),
triggers: {
directMessage: true,
mention: { subscribe: true },
subscribedMessage: true,
},
});// app/api/slack/route.ts
import { waitUntil } from "@vercel/functions";
import { loadChatSdkConnector } from "@little-stack/harness/connectors/runtime";
const supportSlack = await loadChatSdkConnector({
agentDir: "agents/support",
connector: "slack",
});
export async function POST(req: Request) {
return supportSlack.webhook(req, { waitUntil });
}loadChatSdkConnector(...) also returns the created adapter as adapter for platform-specific
runtime hooks. For Discord, use this to start a Gateway listener from a protected cron route; regular
DMs, mentions, and reactions do not arrive through the HTTP interactions endpoint alone.
By default, platform sessions are keyed as platform:threadId, and message history is text-only —
apart from files on the incoming message, which are inlined (see Attachments).
Tool calls and rich UI parts are not reconstructed from platform history.
Direct messages and mentions are enabled by default; set mention: false when a connector should
only respond to direct messages.
Passing the string connector id lets Little Harness use the connector folder as the source of truth
for connector identity. It also enables connector-scoped tool extension discovery from
agents/support/connectors/slack/tools/*. A statically imported descriptor object plus
connectorId: "slack" is functionally equivalent — it gets the same folder tool discovery, session
attachment, and mirror delivery — and it is the typed option: a descriptor reference infers
TExtraBody (and TUser for web-rich), whereas a string reference is typed unknown. Use whichever
fits the route; neither is universally preferred. Loading a descriptor object without connectorId
emits one console.warn per loadChatSdkConnector/loadWebRichConnector call and disables those three
features (the agent still answers). Descriptor tools (see Connector Tools) are the
exception — they load even without connectorId.
Telegram, Discord, Teams, and other Chat SDK adapters use the same connector shape. For example:
// agents/support/connectors/telegram/connector.ts
import { createRedisState } from "@chat-adapter/state-redis";
import { createTelegramAdapter } from "@chat-adapter/telegram";
import { chatSdkConnector } from "@little-stack/harness/connectors/runtime";
export default chatSdkConnector({
userName: "support",
adapter: { name: "telegram", create: () => createTelegramAdapter() },
state: () => createRedisState(),
triggers: { directMessage: true },
});Email works the same way. emailConnector(...) builds a Chat SDK connector that gives the agent its
own address, so mail sent to it starts a run and the reply goes back in the same thread. It adds sender
checks, loop protection, and quote removal that email needs. See Email Connector.
Attachments
A file dropped into a chat thread arrives on the message as an attachment. The connector fetches the
attachments of the incoming message and adds them to that message as UIMessage file parts, which
puts them in two places at once: the app's chat.stageMessage
hook (to write them into the session filesystem for tools and shell commands to work with) and the
model's own context.
export default chatSdkConnector({
userName: "support",
adapter: { name: "slack", create: () => createSlackAdapter() },
state: () => createRedisState(),
attachments: {
maxBytes: 20 * 1024 * 1024,
onSkipped: ({ reason, attachment }) => log.warn("attachment skipped", { reason, name: attachment.name }),
},
});Bytes come from the attachment's own data, or from its fetchData() — which is where a platform's
private-URL authentication lives, so a Slack upload works without any credential handling in your
code. The connector never fetches an attachment's url itself; an attachment that offers only a URL
is reported as unavailable rather than downloaded anonymously.
Nothing about an attachment can fail a turn. A file that is over the cap, that fetchData()
refuses to hand over, or that carries no bytes at all is replaced by a short note in the message text
([attachment "report.pdf" could not be fetched]), so the agent still answers and can say which file
it did not get. The underlying error is passed to onSkipped, never into the model's context — a
platform error can carry a signed URL.
Defaults and options:
maxBytes— largest single file inlined, default 10 MiB. Checked against the platform's reported size first (so an oversized file never crosses the network) and against the fetched bytes second.onSkipped({ reason, attachment, message, adapterName, note, error?, bytes? })— observe files that were not inlined ("oversize","fetch-failed","no-data"). Observation only; throwing is swallowed.attachments: false— opt out entirely, likereactions: false. No attachment is read or fetched.
Only the current message's attachments are inlined. Files on older messages in thread or transcript history stay text-only: re-embedding them on every turn would re-download each file from the platform and grow the request without bound. Stage the files you need to keep into the session filesystem, where they persist across turns.
simulateInbound carries attachments too, and an attachment built with data needs no network at
all — which is how a scripted demo hands the agent a file offline:
await connector.simulateInbound({
thread: createTestThread({ adapterName: "slack" }),
message: createTestMessage({
text: "what does this policy say about refunds?",
attachments: [
{
type: "file",
name: "refund-policy.md",
mimeType: "text/markdown",
data: await readFile("fixtures/refund-policy.md"),
},
],
}),
});Reactions
Thumbs-up and thumbs-down reactions on the agent's replies are recorded as run outcomes by default,
whenever the Chat instance exposes onReaction: 👍 / +1 / thumbs_up records success, 👎 / -1 /
thumbs_down records failure, and removing the reaction withdraws it. The reacting user is stored only
as a pseudonym (anon_<16 hex>). Recording never fails or delays a run.
export default chatSdkConnector({
userName: "support",
adapter: { name: "slack", create: () => createSlackAdapter() },
state: () => createRedisState(),
reactions: { positive: ["white_check_mark"], negative: ["x"] }, // or `false` to opt out
});Read the resulting success rates with little-harness outcomes. See
Outcome Capture for every option and the aggregation API.
Web Text Mode
Use Chat SDK's Web adapter when the browser endpoint should behave like another text-oriented Chat SDK platform.
// agents/support/connectors/web-text/connector.ts
import { createRedisState } from "@chat-adapter/state-redis";
import { createWebAdapter } from "@chat-adapter/web";
import { chatSdkConnector } from "@little-stack/harness/connectors/runtime";
export default chatSdkConnector({
userName: "support",
adapter: {
name: "web",
create: () => createWebAdapter({ userName: "support", getUser }),
},
state: () => createRedisState(),
triggers: { directMessage: true },
history: "latest",
});// app/api/chat/route.ts
import { loadChatSdkConnector } from "@little-stack/harness/connectors/runtime";
const supportWebText = await loadChatSdkConnector({
agentDir: "agents/support",
connector: "web-text",
});
export async function POST(req: Request) {
return supportWebText.webhook(req);
}For web text mode, webhook(req) returns the stream response consumed by the browser. It is useful
when the same bot behavior should work across web and platform chat surfaces, but it is not the rich
tool-rendering path. Chat SDK connector history can use thread history, Chat SDK transcripts, a
custom history callback, or the latest message; raw useChat request history belongs to web-rich
mode.
Web Rich Mode
Use webRichConnector(...) when the browser should receive Little Harness UIMessage.parts,
including typed tool output parts.
// agents/support/connectors/web-rich/connector.ts
import { webRichConnector } from "@little-stack/harness/connectors/runtime";
export default webRichConnector({
authenticate: async (request) => {
const user = await getUser(request);
return user ? { id: user.id, name: user.name } : null;
},
session: ({ body, user }) => `web:${user.id}:${body.id}`,
history: { source: "request" },
extraBody: ({ user }) => ({ userId: user.id }),
});// app/api/chat/route.ts
import { loadWebRichConnector } from "@little-stack/harness/connectors/runtime";
const supportWeb = await loadWebRichConnector({
agentDir: "agents/support",
connector: "web-rich",
});
export async function POST(req: Request) {
return supportWeb.POST(req);
}Web-rich fidelity is live-stream fidelity. Current replay/resume streams are reconstructed from stored text and are text-only.
Portable Sessions And Delivery
Point two surfaces at one harness session by returning the same session id from each connector, then
let the second surface keep the first one mirrored. When a string connector id or explicit
connectorId is set, each run records its endpoint in /session/.harness/connectors.json, so a
session can remember several surfaces at once.
Delivery states
Every recorded endpoint has a delivery state:
active— receives the live streamed reply for the current run. Exactly one per run: the surface handling the request.passive— remembered so the session can resume there later, but receives nothing. The default state a previousactiveis demoted to.mirror— receives a text copy through its deliverer after the active run finishes successfully.disabled— remembered but never delivered to. Never auto-modified by another surface's attach.
Only records that are currently active are demoted when a new surface takes over; passive,
mirror, and disabled records are left alone. If a mirror/disabled surface answers one turn
(so it is briefly promoted to active by a same-key re-attach), its prior state is remembered and
restored the next time it is demoted — one reply from a mirrored surface no longer permanently drops
it out of mirroring.
First-class two-surface flow
Three things make mirroring work without any manual registry calls:
- Both connectors resolve the same session id.
- The run that takes over passes
delivery: { previousActive: "mirror" }, so the surface it replaces is demoted tomirrorinstead ofpassive. - The mirror target declares a descriptor-owned
deliverdescribing how to post text to itself.
// agents/support/connectors/slack/connector.ts
import { createSlackAdapter } from "@chat-adapter/slack";
import { createRedisState } from "@chat-adapter/state-redis";
import { chatSdkConnector } from "@little-stack/harness/connectors/runtime";
export default chatSdkConnector({
userName: "support",
adapter: { name: "slack", create: () => createSlackAdapter() },
state: () => createRedisState(),
session: ({ message }) => `support:${message.userKey ?? message.author.userId}`,
// How to post to this Slack thread when Slack is another surface's mirror target.
deliver: async ({ text, target }) => {
await postSlackThread(target.endpoint.threadId!, text);
},
});// agents/support/connectors/web-rich/connector.ts
import { webRichConnector } from "@little-stack/harness/connectors/runtime";
export default webRichConnector({
authenticate: async (request) => {
const user = await getUser(request);
return user ? { id: user.id, name: user.name } : null;
},
// Same key format as the Slack connector, so both surfaces resolve one session.
session: ({ user }) => `support:${user.id}`,
history: { source: "request" },
});// app/api/chat/route.ts — web-rich takes over, keeps Slack mirrored
import { loadWebRichConnector } from "@little-stack/harness/connectors/runtime";
const supportWeb = await loadWebRichConnector({
agentDir: "agents/support",
connector: "web-rich",
delivery: { previousActive: "mirror" },
});
export async function POST(req: Request) {
return supportWeb.POST(req);
}Now a reply generated on the web surface is streamed to the browser and, once the run finishes,
mirrored to the Slack thread via Slack's descriptor deliver. The web route never names Slack: the
loader discovers the mirror target from the registry and resolves its deliverer by connector id.
A web-rich surface can be a mirror target — it may declare a descriptor deliver like any other
connector — but that deliver cannot push into the live browser stream: by the time mirror
delivery runs, the active run's response stream is already closed. So a web-rich deliver must post
the mirrored text to an out-of-band store the client polls or subscribes to (a database row, a
Redis key, a push channel). The demo does exactly this: its web-rich connector's deliver writes into
an outbox that the console reads back. Platform connectors (Slack, Discord, Telegram) are simpler
mirror targets because they can post after the fact directly to their own thread.
Deliverer resolution order
For each mirror target, Little Harness resolves the deliverer in this order:
- An explicit
delivery.deliverers[connectorId]on the active surface's loader — always wins. - Otherwise the target connector's descriptor
deliver, loaded lazily by connector id and cached. - If loading the sibling descriptor fails,
delivery.onErroris called and that target is skipped.
A target with neither an explicit deliverer nor a descriptor deliver is skipped. Failed harness runs
do not mirror. Deliverer failures never fail the active response; they go to delivery.onError:
const supportWeb = await loadWebRichConnector({
agentDir: "agents/support",
connector: "web-rich",
delivery: {
previousActive: "mirror",
// Optional: override the target's descriptor deliver from this route.
deliverers: {
slack: async ({ text, target }) => postSlackThread(target.endpoint.threadId!, text),
},
onError: async ({ error, target }) => {
console.error("Mirror delivery failed", target?.connectorId, error);
},
},
});Manual attach / detach / set delivery
The loaders cover the common case. For advanced control, drive the registry directly. Build endpoint ids with the exported builders so your refs match what the loaders write — a mismatched id creates a duplicate attachment rather than updating the intended one:
import {
attachSessionConnector,
detachSessionConnector,
setSessionConnectorDelivery,
chatSdkEndpointId,
webRichEndpointId,
listSessionConnectors,
} from "@little-stack/harness/connectors/runtime";
const session = await harness.sessions.getOrCreate({ id: "support:user_1" });
// Register (or re-register) a mirror endpoint explicitly.
await attachSessionConnector(
session,
{
connectorId: "slack",
kind: "chat-sdk",
delivery: "mirror",
endpoint: { id: chatSdkEndpointId("slack", threadId), platform: "slack", threadId },
},
{ previousActive: "mirror" },
);
// Change a surface's delivery state (clears any restore stickiness); throws if the ref is unknown.
await setSessionConnectorDelivery(
session,
{ connectorId: "web-rich", endpointId: webRichEndpointId(userId, conversationId) },
"disabled",
);
// Remove a surface. Returns false when nothing matched.
await detachSessionConnector(session, {
connectorId: "slack",
endpointId: chatSdkEndpointId("slack", threadId),
});
const attached = await listSessionConnectors(session);attachSessionConnector defaults previousActive to "passive". chatSdkEndpointId(adapterName, threadId) produces ${adapterName}:${threadId}; webRichEndpointId(userId, conversationId) produces
${userId}:${conversationId} (the web conversationId is the request body id).
Connector registry reads and writes are serialized inside one runtime process. That protects local and
single-instance hosts from same-session read/write races. If the same session can run on multiple
serverless instances, provide a host-level lock, compare-and-swap, or another shared concurrency
control for session files before relying on portable connector updates as globally atomic. If the
registry file is ever corrupted, Little Harness preserves the original alongside it as
connectors.recovered-*.json, resets the registry to empty, and logs one console.warn.
Connector Tools
Tool availability is derived from structure. Where a tool lives, and whether it has an execute,
determines which connectors see it — you usually don't write a tool whitelist:
- Global tool —
tools/<name>.tswith anexecute. Available on every connector. - Abstract capability —
tools/<name>.tswithout anexecute. A contract that's exposed only on connectors that provide an implementation for it. - Connector-only tool — any tool under
connectors/<id>/tools/. Available only on connector<id>.
The file name is the model-facing tool id, and the connector id comes from the folder name, so connector
tool files never repeat connector: "slack". A flat connectors/slack.ts connector still picks up a
sibling connectors/slack/tools/ directory. Tool filenames are validated with the same reserved/invalid
name policy as agent tools: bash, __proto__, constructor, prototype, and names outside
[A-Za-z_][A-Za-z0-9_-]{0,63} are rejected at load rather than silently overwritten by the runtime
shell tool.
Reading the harness context (typed)
A tool execute(input, options) receives the AI SDK ToolExecutionOptions with the Little Harness
execution context (session, files, artifacts, connector, extraBody, ...) spread onto it. The
AI SDK types do not declare the harness half, so read it through harnessToolContext<TExtraBody> — no
casts, full autocomplete on ctx.extraBody and ctx.connector.endpoint:
// agents/support/connectors/slack/tools/post-to-channel.ts
import { tool } from "ai";
import { z } from "zod";
import { harnessToolContext } from "@little-stack/harness/connectors/runtime";
type SupportExtraBody = { slack: SlackClient };
export default tool({
description: "Post a summary to a Slack channel.",
inputSchema: z.object({ channel: z.string(), summary: z.string() }),
execute: async ({ channel, summary }, options) => {
const ctx = harnessToolContext<SupportExtraBody>(options);
await ctx.extraBody?.slack.chat.postMessage({ channel, text: summary });
return { ok: true };
},
});harnessToolContext throws Tool was executed outside a Little Harness run. when the context is
absent. For a tool that must also run standalone (outside any harness run), use
tryHarnessToolContext<TExtraBody>(options), which returns undefined instead of throwing.
ctx.connector?.endpoint is a typed SessionConnectorEndpoint ({ id, platform?, threadId?, userId?, label? }), so a typo is a compile error rather than a silent undefined.
A tool that exists only on one connector is just a plain tool({ ..., execute }) placed in that
connector's tools/ folder — no extendTool, no shared base.
Shared capability, per-connector implementation (extendTool)
Use extendTool only when a single capability (e.g. a react action both Slack and WhatsApp have, with
different APIs) should expose one consistent tool name/schema across connectors. Declare the abstract
base in tools/, then attach a per-connector execute:
// agents/support/tools/add-reaction.ts (abstract — no execute)
import { tool } from "ai";
import { z } from "zod";
export default tool({
description: "React to the current platform message.",
inputSchema: z.object({ emoji: z.string() }),
});// agents/support/connectors/slack/tools/add-reaction.ts
import addReaction from "../../../tools/add-reaction";
import { extendTool, harnessToolContext } from "@little-stack/harness/connectors/runtime";
type SupportExtraBody = { slack: SlackClient };
export default extendTool(addReaction, {
execute: async ({ emoji }, options) => {
const ctx = harnessToolContext<SupportExtraBody>(options);
await ctx.extraBody?.slack.reactions.add({
emoji,
channel: ctx.connector?.endpoint?.threadId,
});
return { ok: true };
},
});extendTool keeps the base the author imported and passed in: if a same-named base tool also exists in
tools/, the explicitly imported base wins, so a name collision never silently swaps the schema.
Programmatic connector tools (tools)
A connector distributed as an npm package (or an inline descriptor with no connectors/<id>/tools/
folder on disk) can carry tools on the descriptor via tools?: ToolSet. These load even without a
connectorId. Folder-discovered connectors/<id>/tools/* win per name over descriptor tools, and an
execute-less descriptor tool acts as an abstract base only when a folder extension implements it — the
same structural model as files:
export default chatSdkConnector({
// ...
tools: {
"post-to-channel": tool({ /* ..., execute */ }),
},
});At runtime
Global executable tools stay available everywhere; abstract tools appear only where a connector
extension provides execute; a connector extension can override a base tool's implementation; and
connectors without the extension never see the connector-only capability.
The abstract-tool rule now holds everywhere, not just on connector-loaded runs. Plain streamHarness,
generateHarness, and the CLI REPL also hide execute-less tools from the model and emit one warning per
hidden tool: { code: "policy_warning", metadata: { reason: "abstract-tool-hidden", tool } } on the
run's warnings. (bash and runtime tools are unaffected.)
Renderers remain separate from tools. Put web/json-render/React rendering code under ui/ or
connectors/web-rich/renderers/; that folder is a naming convention with no runtime behavior. Do not
expose presentation-only renderers as model tools.
Listing and diagnosing connectors
discoverConnectors(agentDir, { onSkip }) enumerates an agent's connectors. A nested
connectors/<id>/connector.* that fails to import is a hard error; a flat connectors/<id>.ts helper
that fails to load is skipped and reported through onSkip({ id, path, error }) (or a single
console.warn when no handler is given), and one candidate's failure no longer aborts the others. The
CLI exposes the same information — see connectors <name>
and test <name> --connector <id>.
Tool Access Policy
Because availability is structural, you rarely need an explicit whitelist. When you do want to narrow a
connector's tools — most commonly to hide a global tool from one connector — set toolPolicy on the
connector descriptor:
export default chatSdkConnector({
// ...
// Slack stays focused on posting — hide the global browsing tool from it.
toolPolicy: { deny: ["list-releases"] },
});allow— expose ONLY these tool names on the connector (whitelist).deny— remove these tool names from the connector (blacklist). Applied afterallow, sodenywins.
Both are optional; unknown names are ignored. Omit toolPolicy to expose the full structure-derived
toolset. The policy scopes the agent's own tools (global + connector), not runtime tools like bash.
Typed Tool UI
Keep shared agent-specific web UI under ui/, and import type information from individual tool
modules so runtime tool discovery stays automatic.
// agents/support/ui/types.ts
import type { UIMessage } from "ai";
import type { ToolOutputPart, ToolUI } from "@little-stack/harness/connectors";
export type LookupOrderTool = typeof import("../tools/lookup-order").default;
export type SupportUITools = {
"lookup-order": ToolUI<LookupOrderTool>;
};
export type SupportUIMessage = UIMessage<unknown, never, SupportUITools>;
export type LookupOrderOutputPart = ToolOutputPart<"lookup-order", LookupOrderTool>;// agents/support/ui/tool-parts/lookup-order.tsx
import type { LookupOrderOutputPart } from "../types";
export function LookupOrderToolPart({ part }: { part: LookupOrderOutputPart }) {
return <OrderStatusCard order={part.output.order} />;
}The string in ToolOutputPart<"lookup-order", ...> must match the discovered tool filename. For
tools with custom UI renderers, define an outputSchema on the AI SDK tool so output types stay
precise.
Rendering tool parts on the client
The web-rich POST route streams UIMessage.parts, so each tool call arrives as a message part whose
type is tool- followed by the tool filename — lookup-order becomes part.type === "tool-lookup-order". A tool part carries its state through the call; narrow on state === "output-available" before reading part.output:
// app/support/support-chat.tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import type { SupportUIMessage } from "../../agents/support/ui/types";
import { OrderStatusCard } from "./order-status-card";
export function SupportChat() {
// Point the transport at the web-rich POST route, typed with the message shape from ui/types.ts.
const { messages, sendMessage } = useChat<SupportUIMessage>({
transport: new DefaultChatTransport<SupportUIMessage>({ api: "/api/chat" }),
});
return (
<>
{messages.map((message) => (
<article key={message.id}>
{message.parts.map((part, index) => {
if (part.type === "text") return <p key={index}>{part.text}</p>;
if (part.type === "tool-lookup-order" && part.state === "output-available") {
// part.output is typed from LookupOrderTool's outputSchema.
return <OrderStatusCard key={index} order={part.output.order} />;
}
return null;
})}
</article>
))}
</>
);
}Because SupportUIMessage was built from the tool modules, part.type is a discriminated union: the
tool-lookup-order case narrows part to the typed output part, and TypeScript flags a tool name that
does not exist. Tool parts also pass through input-streaming, input-available, and output-error
states before or instead of output-available; render a spinner or error affordance for those.
Deploying
Connectors resolve by string id at runtime: the loaders read the connectors/ directory and
dynamically import connector.* and tool files. This works out of the box in next dev, but a
serverless build (Vercel and similar) prunes files a bundler cannot statically see — so a route that
worked in dev fails in production with Connector not found. (the connector module was pruned) or an
agent.ts ENOENT (the agent folder was pruned).
Fix it in next.config.ts: keep Little Harness and the Chat SDK packages external, and enumerate the
agent files each connector route loads so output tracing includes them.
// next.config.ts (excerpt)
const nextConfig: NextConfig = {
serverExternalPackages: [
"little-harness",
"chat",
"@chat-adapter/discord",
"@chat-adapter/slack",
"@chat-adapter/state-memory",
],
// Connector modules load via dynamic import, which Next output tracing cannot
// follow, so enumerate the runtime files each route needs.
outputFileTracingIncludes: {
"/api/chat": [
"./agents/support/agent.ts",
"./agents/support/instructions.md",
"./agents/support/connectors/web-rich/connector.ts",
"./agents/support/tools/lookup-orders.ts",
// ...every agent.ts / tool / connector file this route loads at runtime
],
},
outputFileTracingExcludes: {
"/api/chat": ["./agents/support/**/*.test.ts", "./agents/support/ui/**/*"],
},
};List one outputFileTracingIncludes entry per connector route, and exclude tests and browser-only
ui/ files so they do not bloat the function. The demo's next.config.ts shows a complete multi-route
example.
To skip the dynamic connector import — the part output tracing cannot follow — pass a statically
imported descriptor object with an explicit connectorId and carry the connector's tools on the
descriptor's tools, instead of a string id:
import supportWebRich from "../../agents/support/connectors/web-rich/connector";
import { loadWebRichConnector } from "@little-stack/harness/connectors/runtime";
const supportWeb = await loadWebRichConnector({
agentDir: "agents/support",
connector: supportWebRich, // statically imported — the bundler traces it
connectorId: "web-rich",
});The connector module is now traced normally by the static import. A descriptor object plus
connectorId tolerates a missing connectors/<id>/ folder: connector-scoped folder-tool discovery
returns {} rather than throwing Connector not found., so if the descriptor carries every
connector-only tool on its tools, no connectors/<id>/ files need to exist on disk at all. A
connectors/<id>/tools/ folder that exists without a connector.* module is still honored, too:
its on-disk tools load and merge, with folder tools winning per name over the descriptor's tools. (A
string connector reference still requires the folder — that is where the loader reads the descriptor
from.)
What this does not remove is the base-agent read: loadHarness still loads agent.ts, tools/,
skills, and instructions from agentDir, so those files still need outputFileTracingIncludes
entries. Only supplying your own loadHarness option (resolving the harness without the agent-folder
read) removes that requirement.
Module Loading
Connector modules (connector.* and connectors/<id>/tools/*) load through jiti in a separate
module graph from your statically imported route code. Module-level state is therefore duplicated
across that boundary: a file imported statically by a route and the same file imported by a connector
tool each get their own instance, so a module-level singleton is not shared between them. Keep state
that must be shared on globalThis or in an external store (Redis, a database) rather than a
module-level variable.
Testing Connectors
Chat SDK connectors run end to end without a real platform. loadChatSdkConnector accepts a
createChat constructor, and the loaded connector exposes simulateInbound to push a synthetic
message through the real run pipeline. @little-stack/harness/connectors/runtime ships test doubles that
satisfy the Chat SDK structural types with zero casts:
createTestChat({ adapterName? })— aChatSdkChatConstructorwith no-op webhooks and recordedonDirectMessage/onNewMention/onSubscribedMessagehandlers.createTestThread({ id?, adapterName?, isDM?, messages? })— aChatSdkThreadwhosepost()collects streamed reply text into aposts: string[]array;subscribe/startTypingare no-op recorders.createTestMessage({ text?, attachments?, ... })— aChatSdkMessagewith sensible defaults. Passattachmentswithdatato simulate a file upload offline (see Attachments).
import {
loadChatSdkConnector,
createTestChat,
createTestThread,
createTestMessage,
} from "@little-stack/harness/connectors/runtime";
import supportSlack from "../agents/support/connectors/slack/connector";
const connector = await loadChatSdkConnector({
agentDir: "agents/support",
connector: supportSlack,
connectorId: "slack",
createChat: createTestChat(),
});
const thread = createTestThread({ adapterName: "slack" });
const message = createTestMessage({ text: "where is my order?" });
await connector.simulateInbound({ thread, message });
// The streamed reply text was collected on the thread.
console.log(thread.posts.join(""));simulateInbound runs the same internal pipeline as a real trigger — session resolution, history,
tools, posting, mirror delivery, and the descriptor hooks all fire. It defaults to the
"directMessage" trigger and accepts an explicit trigger for "mention" or "subscribedMessage".
Unlike a real inbound event it bypasses the connector's enabled-trigger check, because calling it is an
explicit developer action. To drive the recorded handlers yourself instead, reach the constructed
instance: createTestChat() returns a constructor (a ChatSdkChatConstructor), not an instance.
The recorded onDirectMessage / onNewMention / onSubscribedMessage handlers live on each
constructed instance's handlers record. The loaded connector's chat is the instance the loader
constructed, so keep a reference to the constructor and read handlers off its typed instances array
(cast-free); loaded.chat is that same instance:
const TestChat = createTestChat();
const loaded = await loadChatSdkConnector({
agentDir: "agents/support",
connector: supportSlack,
connectorId: "slack",
createChat: TestChat,
});
// `loaded.chat` is the same instance; `TestChat.instances[0]` exposes its `handlers` cast-free.
await TestChat.instances[0]!.handlers.directMessage[0]!(thread, message);