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.
onError(error)ErrorResponseGeneration failed. Do not also call onFinish().
onFinish()ContentChunk ENDResponse complete. Call exactly once per request.

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.

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.
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.

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.
  • 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} from '@astropods/adapter-core';
6
7import { serve, MessagingBridge, logger } 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.