Custom Agents
Any type that conforms to AgentProtocol is a first-class agent — the Orchestrator does not distinguish between custom and built-in agents. The protocol extension provides defaults for id, saveChat, and maxToolRounds, so a minimal implementation only needs name, description, and process.
For the full protocol definition and supporting types (AgentInput, AgentContext, AgentEvent) see the Agents overview.
Minimal example: EchoAgent
Section titled “Minimal example: EchoAgent”struct EchoAgent: AgentProtocol { let name = "Echo" let description = "Repeats the input back."
func process( _ input: AgentInput, history: [ConversationMessage], context: AgentContext ) -> AsyncThrowingStream<AgentEvent, any Error> { AsyncThrowingStream { continuation in let text = input.text continuation.yield(.textDelta(text)) continuation.yield(.final(ConversationMessage(role: .assistant, text: text))) continuation.finish() } }}Register it with the orchestrator exactly like a built-in Agent:
let orchestrator = Orchestrator( agents: [EchoAgent()], store: myChatStorage)Overriding defaults
Section titled “Overriding defaults”The protocol extension defaults are:
| Property | Default |
|---|---|
id | slugify(name) |
saveChat | true |
maxToolRounds | 1 |
Override any of them by declaring the property on your type:
struct MyAgent: AgentProtocol { let name = "My Agent" let description = "Does something useful." let maxToolRounds = 10 // allow up to 10 tool-call iterations per turn let saveChat = false // do not persist turns
func process(/* … */) -> AsyncThrowingStream<AgentEvent, any Error> { /* … */ }}Emitting events
Section titled “Emitting events”Your process implementation should yield events in the order callers expect:
- Zero or more
.thinking(String)— extended reasoning tokens, if your model supports them. - One or more
.textDelta(String)— incremental text chunks. - Zero or more
.toolCall(id:name:arguments:)— tool announcements for observability. - Zero or more
.widget(UIPayload)— structured UI payloads for the client. - Exactly one
.final(ConversationMessage)— the completed turn; the orchestrator persists this whensaveChatistrue.
Throw an error into the stream to signal a hard failure. Yield .error(String) for a recoverable, user-visible message that should appear in the chat UI without terminating the stream.
Using AgentContext
Section titled “Using AgentContext”AgentContext carries userId, sessionId, arbitrary params, and an optional span for distributed tracing. Attach child spans to context.span so they appear nested under the orchestrator’s session span:
func process( _ input: AgentInput, history: [ConversationMessage], context: AgentContext) -> AsyncThrowingStream<AgentEvent, any Error> { AsyncThrowingStream { continuation in let task = Task { let span = context.span?.span("my-agent.work", input: nil) // … do work … span?.end(output: nil, error: nil) continuation.finish() } continuation.onTermination = { _ in task.cancel() } }}See Tracing for the full span API.
Related pages
Section titled “Related pages”- Agents overview — protocol contract,
AgentInput,AgentContext,AgentEvent. - Built-in Agent — the general-purpose
Agentstruct you can extend or delegate to. - GroundedAgent — the two-LLM anti-hallucination variant.
- Tools — building a
ToolProvideryour custom agent can call. - Tracing — attaching spans to
AgentContext. - Guides: Extending AgentSquad — broader patterns for customisation.