Custom adapter (Node)

Build a custom adapter with @astropods/adapter-core

View as Markdown

@astropods/adapter-core is the framework-agnostic core. Implement the AgentAdapter interface, hand it to serve(), and the bundled MessagingBridge handles the gRPC streaming loop, audio, feedback, reconnect, and shutdown.

Use this when there’s no first-party adapter for your framework (or no framework at all — you’re calling LLM APIs directly).

Install

$bun add @astropods/adapter-core @astropods/messaging
$# or: npm install @astropods/adapter-core @astropods/messaging

Minimal adapter

1import { serve } from '@astropods/adapter-core';
2import type { AgentAdapter } from '@astropods/adapter-core';
3
4const adapter: AgentAdapter = {
5 name: 'My Agent',
6
7 async stream(prompt, hooks, options) {
8 try {
9 hooks.onChunk('Hello, ');
10 hooks.onChunk(`${prompt}!`);
11 hooks.onFinish();
12 } catch (err) {
13 hooks.onError(err as Error);
14 }
15 },
16
17 getConfig() {
18 return { systemPrompt: 'You are a helpful assistant.', tools: [] };
19 },
20};
21
22serve(adapter);

serve(adapter) connects to localhost:9090 (or GRPC_SERVER_ADDR) and runs until the process exits.

Lifecycle

AgentAdapter

MemberRequiredNotes
name: stringyesDisplay name. Used in logs and AgentConfig.
stream(prompt, hooks, options)yesStream a reply. Call hooks.onChunk/onStatusUpdate/etc. as you go.
getConfig(): MessagingAgentConfigyesReturns { systemPrompt, tools } for playground display.
streamAudio(audio, hooks, options)noHandle audio messages. Omit to reject audio input with a friendly message.
onFeedback(feedback)noReceive thumbs-up/down, text feedback, button clicks, etc.

StreamHooks

Call these from inside stream() and streamAudio(). They translate directly into outbound AgentResponse messages on the gRPC stream.

MethodSendsWhen to call
onChunk(text)ContentChunk (START first, then DELTAs)Each text fragment from the LLM. Bridge handles START/DELTA framing.
onStatusUpdate(status)StatusUpdatePre-content typing indicator, tool execution status.
onTranscript(text)TranscriptAfter STT — replaces the “[audio]” placeholder.
onAudioChunk(data)AudioChunkTTS bytes back to the platform.
onAudioEnd()AudioChunk { done: true }End-of-segment marker after TTS.
onFile(file)FileAttachment on the terminal ContentChunkAfter writing an output inside AGENT_FILES_DIR; call before onFinish().
onError(error)ErrorResponseGeneration failed. Do not also call onFinish().
onFinish()ContentChunk ENDResponse complete. Call exactly once per request.
onTraceContext({ traceparent })Stamps traceContext on the turn’s AgentResponsesOptional. Call once per turn to supply the W3C trace context of the assistant turn. Ignored when traceparent is empty. See Trace context.

StatusUpdate.status values: THINKING, SEARCHING, GENERATING, PROCESSING, ANALYZING, CUSTOM. Use CUSTOM with customMessage.

StreamOptions

The bridge passes this to every stream() / streamAudio() call.

FieldTypeNotes
conversationIdstringStable across the conversation. Use as the memory/session key.
userIdstringSender’s user ID. Use for memory scoping and trace attribution.
platformContextPlatformContextChannel/thread IDs, workspace, event kind, raw platform user ID. Undefined when the message did not originate from a platform adapter (e.g. direct gRPC). See Messaging SDK — PlatformContext.
attachmentsAttachmentInput[]Files attached to this turn. Read the provided path; do not derive a path from the display name or scan the shared directory. See Files in chat.

AudioInput

Passed to streamAudio().

FieldTypeNotes
streamReadableStream<Uint8Array>Raw audio bytes. Pass directly to STT.
configAudioStreamConfigEncoding, sample rate, channels, language hint, source.
filetypestringPre-mapped extension (wav, webm, ogg, mp3, m4a, opus, flac).

FeedbackEvent

Passed to onFeedback(). kind is a stable string discriminator so you don’t have to import proto types to switch on it.

FieldTypeNotes
conversationIdstringConversation the feedback is attached to.
responseIdstringPlatform message ID the feedback targets.
traceContextTraceContextTrace context of the referenced assistant response, when the platform echoes it back. See Trace context.
kindstringSee table below.
userIdstringSubmitter’s user ID. Empty for anonymous/system events.
userNamestringSubmitter’s display name.
textstringPopulated for text (the body), reaction (emoji name).
promptstringPopulated for text (the modal label).

FeedbackEvent.kind values

ValueSource
thumbs_upMessageReaction with THUMBS_UP. Synthesized from the reaction enum.
thumbs_downMessageReaction with THUMBS_DOWN.
reactionMessageReaction with CUSTOM_EMOJI. text holds the emoji name.
textTextFeedback modal submission. text is the body; prompt is the modal label.
button_clickButtonClick from a CardAttachment.
prompt_selectionUser clicked a SuggestedPrompts entry.
stream_controlStreamControl (stop/pause/resume/regenerate).
message_editUser edited their own previous message.
message_deleteUser deleted their own previous message.

onFeedback() may return void or a Promise. The bridge does not await the result — so don’t block the stream on slow I/O. Push to a queue or trigger an async write and return.

Trace context

Call hooks.onTraceContext({ traceparent }) once per turn to attach the turn’s W3C trace context. The bridge stamps it on every AgentResponse it emits for the turn, so a consumer can correlate any response — and the feedback that references it — back to the trace. It’s ignored when traceparent is empty.

createTraceparent(input) formats a native trace/span ID pair into a W3C traceparent string. It 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).

1import { createTraceparent } from '@astropods/adapter-core';
2
3const { traceId, spanId, traceFlags } = span.spanContext();
4hooks.onTraceContext?.({ traceparent: createTraceparent({ traceId, spanId, traceFlags }) });

The first-party framework adapters already emit trace context from their root span — you only wire this up in a custom adapter. See the Messaging SDK for the underlying TraceContext wire shape.

serve(adapter, options?)

Thin wrapper around MessagingBridge that handles process lifecycle.

ParameterTypeRequiredNotes
adapterAgentAdapteryesYour implementation.
optionsServeOptionsno{ serverAddress?: string }.

ServeOptions.serverAddress defaults to process.env.GRPC_SERVER_ADDR || 'localhost:9090'.

Worked example: a plain OpenAI agent

No framework — just calling OpenAI’s streaming chat API and forwarding tokens.

1import OpenAI from 'openai';
2import { serve } from '@astropods/adapter-core';
3import type { AgentAdapter } from '@astropods/adapter-core';
4
5const openai = new OpenAI({
6 apiKey: process.env.ASTRO_GATEWAY_API_KEY,
7 baseURL: process.env.ASTRO_GATEWAY_URL,
8});
9
10const adapter: AgentAdapter = {
11 name: 'OpenAI Direct',
12
13 async stream(prompt, hooks, options) {
14 try {
15 hooks.onStatusUpdate({ status: 'GENERATING' });
16
17 const stream = await openai.chat.completions.create({
18 model: 'claude-sonnet-4-6',
19 messages: [
20 { role: 'system', content: 'You are a helpful assistant.' },
21 { role: 'user', content: prompt },
22 ],
23 stream: true,
24 });
25
26 for await (const chunk of stream) {
27 const text = chunk.choices[0]?.delta?.content;
28 if (text) hooks.onChunk(text);
29 }
30
31 hooks.onFinish();
32 } catch (err) {
33 hooks.onError(err as Error);
34 }
35 },
36
37 getConfig() {
38 return { systemPrompt: 'You are a helpful assistant.', tools: [] };
39 },
40
41 onFeedback(event) {
42 // Push to your evals pipeline, Airtable, etc. Don't block.
43 void recordFeedback(event);
44 },
45};
46
47serve(adapter);

Rules of thumb

  • Call exactly one of onFinish() or onError() per request. Skipping either leaves the user staring at a half-rendered reply.
  • Catch your own exceptions inside stream(). If you let the promise reject, the user sees nothing.
  • Don’t block in onFeedback() — push to a queue or trigger async work and return.
  • Use options.conversationId (not platformContext.threadId) as your memory key.
  • Use only options.attachments as file input for the turn. Write output files to AGENT_FILES_DIR and call onFile before onFinish; see Files in chat.
  • Set up OTEL manually before calling serve() if you want traces. Framework adapters auto-configure this; the raw core does not.

Re-exported types

1import type {
2 AgentAdapter, AudioInput, FeedbackEvent,
3 StreamHooks, StreamOptions, ServeOptions,
4 PlatformContext, PlatformContextEventKind,
5 TraceContext, TraceparentInput,
6} from '@astropods/adapter-core';
7
8import { serve, MessagingBridge, logger, createTraceparent } from '@astropods/adapter-core';

logger is a Pino instance. Use it for adapter logs so output stays consistent with the bridge.

See Messaging SDK (Node) for the underlying proto shapes referenced by StreamHooks and PlatformContext.