Responder
The Responder takes raw AgentResult from the runtime and formats it for the delivery channel.
Protocol
class Responder(Protocol): async def format( self, output: AgentResult, channel: Channel, ctx: ExecContext ) -> Response: ...export interface Responder { format(output: AgentResult, channel: Channel, ctx: ExecContext): Promise<Response>;}type Responder interface { Format(ctx *nctx.ExecContext, output runtime.AgentResult, channel Channel) (Response, error)}Value types
@dataclass(frozen=True)class Channel: name: str # "api", "slack", "websocket", "sms" supports_markdown: bool # can render **bold**, `code`, etc. supports_media: bool # can display images, files, cards max_length: int # 0 = unlimited
@dataclassclass Response: text: str channel: Channel media: list[str] # URLs or base64 attachments metadata: dict[str, str] # channel-specific extrasexport interface Channel { readonly name: string; readonly supportsMarkdown: boolean; readonly supportsMedia: boolean; readonly maxLength: number; // 0 = unlimited}
export interface Response { readonly text: string; readonly channel: Channel; readonly media: ReadonlyArray<string>; readonly metadata: Readonly<Record<string, string>>;}type Channel struct { Name string SupportsMarkdown bool SupportsMedia bool MaxLength int // 0 = unlimited}
type Response struct { Text string Channel Channel Media []string Metadata map[string]string}Built-in channel presets:
from nerva.responder import API_CHANNEL, WEBSOCKET_CHANNEL
API_CHANNEL # name="api", markdown=False, media=True, max_length=0WEBSOCKET_CHANNEL # name="websocket", markdown=True, media=True, max_length=0import { API_CHANNEL, WEBSOCKET_CHANNEL } from "nerva/responder";
API_CHANNEL // name="api", markdown=false, media=true, maxLength=0WEBSOCKET_CHANNEL // name="websocket", markdown=true, media=true, maxLength=0import "github.com/otomus/nerva/go/responder"
responder.APIChannel // Name="api", SupportsMarkdown=false, SupportsMedia=trueresponder.WebSocketChannel // Name="websocket", SupportsMarkdown=true, SupportsMedia=trueStrategies
PassthroughResponder
Returns the raw agent output with no transformation. Use for APIs and programmatic consumers.
from nerva.responder.passthrough import PassthroughResponder
responder = PassthroughResponder()response = await responder.format(agent_result, API_CHANNEL, ctx)# response.text == agent_result.output (untouched)import { PassthroughResponder } from "nerva/responder/passthrough";import { API_CHANNEL } from "nerva/responder";
const responder = new PassthroughResponder();const response = await responder.format(agentResult, API_CHANNEL, ctx);// response.text === agentResult.output (untouched)import "github.com/otomus/nerva/go/responder"
r := responder.NewPassthroughResponder()response, err := r.Format(ctx, agentResult, responder.APIChannel)// response.Text == agentResult.Output (untouched)ToneResponder
Rewrites the output through an LLM to apply personality and tone. The raw content is preserved; only phrasing changes.
from nerva.responder.tone import ToneResponder
responder = ToneResponder( llm=my_llm_client, system_prompt="You are a friendly travel assistant. Keep responses concise and use casual language.",)
response = await responder.format(agent_result, WEBSOCKET_CHANNEL, ctx)# Raw: "Flight LH123 departs at 14:30 from TLV to BER"# Toned: "Found you a flight! LH123 leaves Tel Aviv at 2:30 PM heading to Berlin."import { ToneResponder } from "nerva/responder/tone";import { WEBSOCKET_CHANNEL } from "nerva/responder";
const responder = new ToneResponder( myLlmRewriteFn, // (systemPrompt: string, text: string) => Promise<string> "friendly and casual",);
const response = await responder.format(agentResult, WEBSOCKET_CHANNEL, ctx);// Raw: "Flight LH123 departs at 14:30 from TLV to BER"// Toned: "Found you a flight! LH123 leaves Tel Aviv at 2:30 PM heading to Berlin."// The Go SDK does not include a ToneResponder out of the box.// Implement the Responder interface and call your LLM client// inside Format() to apply tone rewriting.type ToneResponder struct { Rewrite func(systemPrompt, text string) (string, error) Tone string}
func (t *ToneResponder) Format( ctx *nctx.ExecContext, output runtime.AgentResult, channel responder.Channel,) (responder.Response, error) { rewritten, err := t.Rewrite( fmt.Sprintf("Rewrite the following text in a %s tone.", t.Tone), output.Output, ) if err != nil { return responder.Response{}, err } return responder.Response{Text: rewritten, Channel: channel}, nil}MultimodalResponder
Enriches output with media attachments, cards, and buttons based on channel capabilities. Falls back to text-only for channels that do not support media.
from nerva.responder.multimodal import MultimodalResponder
responder = MultimodalResponder( media_resolver=my_media_resolver, # resolves media references to URLs)
response = await responder.format(agent_result, slack_channel, ctx)# response.media = ["https://cdn.example.com/weather-map-berlin.png"]# response.metadata = {"blocks": [...]} # Slack Block Kitimport { MultimodalResponder, createTextBlock, createImageBlock, createCardBlock,} from "nerva/responder/multimodal";
const responder = new MultimodalResponder([ createTextBlock("Here are the results:"), createImageBlock("https://cdn.example.com/chart.png", "Sales chart"), createCardBlock("Summary", "Revenue increased 15%"),]);
const response = await responder.format(agentResult, slackChannel, ctx);// response.media = ["https://cdn.example.com/chart.png"]// response.metadata.blocks contains serialized block data// The Go SDK does not include a MultimodalResponder out of the box.// Implement the Responder interface to handle media enrichment// based on channel.SupportsMedia and channel.SupportsMarkdown.func (m *MyMultimodalResponder) Format( ctx *nctx.ExecContext, output runtime.AgentResult, channel responder.Channel,) (responder.Response, error) { media := []string{} if channel.SupportsMedia { media = resolveMediaURLs(output) } return responder.Response{ Text: output.Output, Channel: channel, Media: media, }, nil}Channel awareness
Define custom channels to control formatting:
sms_channel = Channel( name="sms", supports_markdown=False, supports_media=False, max_length=160,)
slack_channel = Channel( name="slack", supports_markdown=True, supports_media=True, max_length=4000,)
# Responder adapts output to each channel's constraintssms_response = await responder.format(result, sms_channel, ctx) # truncated to 160 chars, no markdownslack_response = await responder.format(result, slack_channel, ctx) # full markdown + imagesimport { createChannel } from "nerva/responder";
const smsChannel = createChannel("sms", { supportsMarkdown: false, supportsMedia: false, maxLength: 160,});
const slackChannel = createChannel("slack", { supportsMarkdown: true, supportsMedia: true, maxLength: 4000,});
// Responder adapts output to each channel's constraintsconst smsResponse = await responder.format(result, smsChannel, ctx); // truncated to 160 charsconst slackResponse = await responder.format(result, slackChannel, ctx); // full markdown + imagessmsChannel := responder.Channel{ Name: "sms", SupportsMarkdown: false, SupportsMedia: false, MaxLength: 160,}
slackChannel := responder.Channel{ Name: "slack", SupportsMarkdown: true, SupportsMedia: true, MaxLength: 4000,}
// Responder adapts output to each channel's constraintssmsResp, _ := r.Format(ctx, result, smsChannel) // truncated to 160 charsslackResp, _ := r.Format(ctx, result, slackChannel) // full markdown + imagesStreaming
When ctx.stream is set, the Responder formats each chunk as it arrives rather than waiting for the full output:
ctx = ExecContext.create(user_id="user_1", stream=my_stream_sink)
# Chunks flow through: Runtime -> Responder.format_chunk() -> ctx.stream -> clientasync for chunk in orchestrator.stream("Book a flight", ctx): await websocket.send(chunk)import { StreamingResponder } from "nerva/responder/streaming";
const streamer = new StreamingResponder("websocket");
// Format individual chunks for the target transportconst formatted = streamer.formatChunk("partial response text");// '{"content":"partial response text"}'
// SSE formatconst sseStreamer = new StreamingResponder("sse");const sseFormatted = sseStreamer.formatChunk("partial response text");// 'data: {"content":"partial response text"}\n\n'// Stream chunks manually using the Responder interface.// Format each chunk as it arrives and write to the connection.for chunk := range chunks { resp, err := r.Format(ctx, runtime.AgentResult{Output: chunk}, wsChannel) if err != nil { break } conn.Write([]byte(resp.Text))}