@astropods/adapter-core
Framework-agnostic bridge between TypeScript agents and the Astropods messaging service.
Installation
bun add @astropods/adapter-core
# or
npm install @astropods/adapter-core
Usage
If you're using a supported framework, use the pre-built adapter package instead (e.g. @astropods/adapter-mastra). Use this package directly to connect a custom or unsupported framework.
Implement the AgentAdapter interface, then call serve():
import { serve } from "@astropods/adapter-core";
import type { AgentAdapter } from "@astropods/adapter-core";
const adapter: AgentAdapter = {
name: "My Agent",
async stream(prompt, hooks, options) {
try {
hooks.onChunk("Hello!");
hooks.onFinish();
} catch (err) {
hooks.onError(err as Error);
}
},
getConfig() {
return { systemPrompt: "You are a helpful assistant.", tools: [] };
},
};
serve(adapter);
serve() blocks until SIGINT or SIGTERM. Under ast dev, GRPC_SERVER_ADDR is injected automatically.
API
AgentAdapter
| Member | Description |
|---|---|
name: string | Display name used in logs and registration |
stream(prompt, hooks, options): Promise<void> | Stream a response, invoking hooks as the agent progresses |
streamAudio?(audio, hooks, options): Promise<void> | Optional — handle voice input |
getConfig(): AgentConfig | Return { systemPrompt, tools } for playground display |
onFeedback?(event): void | Promise<void> | Optional — receive inbound platform feedback (thumbs up/down, comments, button clicks) |
StreamHooks
Call these inside stream() as the agent produces output:
| Method | When to call |
|---|---|
onChunk(text) | Each text token or fragment from the LLM |
onStatusUpdate({ status }) | Agent state change — valid values: THINKING, SEARCHING, GENERATING, PROCESSING, ANALYZING, CUSTOM |
onFinish() | Response complete — call exactly once per request |
onError(error) | Error occurred — call instead of onFinish |
onTraceContext({ traceparent }) | Optional — once per turn, to supply the W3C trace context of the assistant turn. The bridge stamps it on every response it emits for the turn, so a consumer can correlate any response back to its trace. Ignored if traceparent is empty. |
onFile({ name, mimeType?, size? }) | A file the agent wrote to its files dir, delivered with the reply. Tool code calls attachFile instead. |
attachFile(input)
Writes a file to the agent's files dir (AGENT_FILES_DIR) and attaches it to the reply. Web chat shows it as a download chip, and Slack uploads it into the thread. Call it from a tool while the agent handles a message, in any framework adapter. It throws outside one.
import { attachFile } from "@astropods/adapter-core";
await attachFile({ name: "account-plan.csv", data: csv, mimeType: "text/csv" });
name is one path segment. Omit data to attach a file that is already in the files dir. It returns the file's name. Every user's files share that dir, so with data a name already there gets a number: summary.md becomes summary-2.md. Return files through attachFile rather than writing into AGENT_FILES_DIR directly, because a direct write can replace another thread's file. A file over the deployment's file size limit (FILES_MAX_MB, 100 MiB when unset, 0 for no limit) throws, so the agent knows before it replies.
@astropods/adapter-core/files
Framework-neutral functions for reading the files a user attaches. They take the path from StreamOptions.attachments. Every thread's and user's files share AGENT_FILES_DIR, so the functions open only the files the current conversation received as attachments or wrote with attachFile, and they throw outside a turn. The bridge saves that list, up to 100 files for each conversation, in AGENT_FILES_DIR/.conversations, so it lasts through a restart. A file deleted and replaced at the same path is not on the list. The Mastra and AI SDK adapters wrap them as tools in fileTools().
| Function | Returns |
|---|---|
inspectFile(path) | The file's kind (pdf, docx, xlsx, zip, text, binary), its size, its text length, and for a table each sheet's columns and exact row count. The kind is sniffed from the bytes, because staged uploads have no extension. A PDF with no text layer, such as a scan, has scanned: true. |
readFilePage(path, offset?) | One PAGE_CHARS (20,000-character) page of a PDF, DOCX, or text file, with nextOffset (null on the last page) and totalChars. It throws for a PDF with no text layer, which needs OCR. |
readTable(path, { sheet?, offset?, limit? }) | One page of a CSV or XLSX table: the header as columns, then limit data rows (PAGE_ROWS, 100, by default) starting at offset, with nextOffset (null on the last page). CSV cells are strings and XLSX cells keep their types. A CSV is streamed, so a page late in a large file does not load the whole file. |
createFile(name, content, mimeType?) | Writes a text file with attachFile and returns { attached, path }: the name the user sees and the path the read functions open in a later turn. |
attachmentNote(attachments, images?) | The line an adapter appends to the turn's text that names each attached file and its path. |
The PDF, DOCX, XLSX, and CSV parsers load on first use, so an agent that never calls these functions does not load them.
Analyzing tables
These functions deliver a file's contents and do no analysis. To count, total, group, or join rows across a large table, give the agent a tool that runs a query engine on the attachment's path, such as DuckDB (read_csv_auto(path)) or pandas in a code interpreter. Pass the path through resolveAgentFile, which rejects any file the conversation did not receive or create. If the model writes the query, limit what the engine can read, because DuckDB and pandas can open any path on the machine.
createTraceparent(input)
Formats a native trace/span ID pair into a W3C traceparent string to hand to onTraceContext. Returns "" for invalid or all-zero IDs, so it's safe to pass raw OpenTelemetry span context. traceFlags accepts a number or hex string and defaults to 01 (sampled).
import { createTraceparent } from "@astropods/adapter-core";
const { traceId, spanId, traceFlags } = span.spanContext();
hooks.onTraceContext?.({ traceparent: createTraceparent({ traceId, spanId, traceFlags }) });
StreamOptions
Per-request context passed to stream():
| Field | Description |
|---|---|
conversationId | Stable ID for the conversation thread |
userId | ID of the user who sent the message |
userName? | string | undefined: the sender's name on the source platform, such as their full name in Slack. undefined when the platform sent none. |
platformContext? | PlatformContext | undefined — platform-specific fields (channel, thread, workspace, event kind). undefined for messages from non-platform sources (playground, direct gRPC). |
history? | HistoryInput | undefined: the turns before this message, set only when they differ from what the agent last answered. See Edited messages. |
Edited messages
In the web chat a user can edit a message they sent, or switch to another version of the conversation. The next message then carries history: the turns before it on the version the user is on, oldest first. It also arrives after a turn the agent did not finish answering. The turns are text only; a user turn that carried only files reads as a line naming them. Replace what you store for conversationId with history.messages, then handle the prompt. An empty messages means there are no earlier turns. isComplete is false when older turns were left out to bound the size.
Declare that you do this with supportsHistory: true in getConfig(). The chat offers editing only for agents that set it. The packaged adapters set it and reset their framework's memory for you.
getConfig() {
return { systemPrompt: "", tools: [], supportsHistory: true };
}
Using platformContext
PlatformContext exposes the source-event fields adapters often need to branch on — the channel and thread to reply into, the workspace, the bot's own user ID, and an eventKind enum that distinguishes a DM from an @-mention from a thread reply without having to inspect message content.
import type { AgentAdapter, PlatformContext } from "@astropods/adapter-core";
const adapter: AgentAdapter = {
name: "My Agent",
async stream(prompt, hooks, { platformContext }) {
if (platformContext?.eventKind === "EVENT_KIND_APP_MENTION") {
hooks.onChunk(`You @-mentioned me in ${platformContext.channelName ?? platformContext.channelId}.`);
} else {
hooks.onChunk("Hello!");
}
hooks.onFinish();
},
getConfig() {
return { systemPrompt: "", tools: [] };
},
};
Always null-check first — platformContext is undefined for messages from the playground or direct gRPC clients. See PlatformContext for the full field list.
serve(adapter, options?)
Connects the adapter to the messaging service and blocks until shutdown.
import { serve } from "@astropods/adapter-core";
// Override the gRPC address (default: GRPC_SERVER_ADDR env var or localhost:9090)
serve(adapter, { serverAddress: "astro-messaging:9090" });
MessagingBridge
serve() is a thin wrapper around MessagingBridge. Use it directly if you need lifecycle control:
import { MessagingBridge } from "@astropods/adapter-core";
const bridge = new MessagingBridge(adapter);
await bridge.start();
HTTP outbound instrumentation
@astropods/adapter-core/instrument patches globalThis.fetch so every outbound HTTP request becomes an OpenTelemetry CLIENT span. Spans are sent to ${OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces. When that env var is unset, the import is a no-op.
Import it once, before any code that issues fetch — typically the first line of your entry point:
import "@astropods/adapter-core/instrument";
// ...the rest of your imports
Or call it explicitly:
import { instrumentHttp } from "@astropods/adapter-core";
instrumentHttp();
Both forms are idempotent and share a tracer provider with other Astropods adapters in the same process.
Connections
A connection is a third-party account, such as GitHub, that the chatting user
lets the agent use as them. The agent declares it under connections in
astropods.yml, the user allows it in chat, and the agent fetches a token per
turn.
import { ConnectionClient, ConnectionError } from "@astropods/adapter-core/connections";
const connections = new ConnectionClient();
const { accessToken } = await connections.getToken("github", options.userId);
new ConnectionClient() reads ASTRO_AUTHZ_TOKEN from the environment and
takes the server URL from that token. Pass the turn's userId: the server
returns a token only for a user who allowed this agent and has messaged it in
web chat in the last 15 minutes.
A token with an expiry is reused until a minute before it expires. A token with none is fetched again on every call, so a revoke takes effect at once.
A refusal throws ConnectionError with a code:
code | Tell the user |
|---|---|
not_consented | Allow the connection in chat |
not_active | Send a message in web chat |
not_connected | Connect the provider on their personal account |
needs_reauthorization | Reconnect the provider |
unavailable | Try again later |
Sandboxes
A sandbox is an isolated machine an agent creates to run commands and hold files for one conversation. It sleeps when the conversation goes quiet and bills for the compute it uses.
import { SandboxClient } from "@astropods/adapter-core/sandbox";
const sandboxes = new SandboxClient();
const result = await sandboxes.exec(conversationId, {
command: ["/bin/sh", "-c", "npm test"],
});
console.log(result.exitCode, result.stdout);
new SandboxClient() reads ASTRO_AUTHZ_TOKEN from the environment and takes
the server URL from that token, so nothing has to be configured.
Call exec on every turn. It attaches on the first call for a name and reuses
the sandbox after, and the server settles two concurrent first calls onto one
sandbox rather than two. Key by conversation or thread id: two conversations
sharing one sandbox share its files.
| Method | Does |
|---|---|
exec(name, request) | Attaches if needed, then runs a command |
attach(name, class?) | Returns a handle without running anything |
get(name) / list() | Records, with no credentials in them |
stop(name) | Stops compute early. Optional; an idle sandbox sleeps on its own |
delete(name) | Drops the sandbox and its files |
A non-zero exit code and a timed-out command are both results, not thrown
errors: check exitCode and timedOut. Thrown errors mean the request itself
failed. SandboxNotEnabledError means the account has not turned sandboxes
on, in account or organization settings.
The endpoint and the two credentials a sandbox needs are not part of this surface. The client refreshes them, and re-attaches once when a sandbox has been replaced, so an agent never handles either.
Limits
Output is buffered until a command exits and capped at 1 MiB per stream, and
there is no way to stream it, poll a background process or signal one. A
sandbox lives at most 8 hours; after that the next exec gets a fresh one with
an empty workspace. Files do not survive that.