The Amp SDK uses a streaming architecture to deliver messages in real time as the agent works.
How It Works
AmpSdk.execute/2 returns a lazy Stream that yields typed message structs. The stream is backed by Stream.resource/3 — messages are produced from the shared core session API and projected back into the public Amp SDK message types, and the stream halts automatically when a final result or error is received.
The stream consumer uses selective receive for the internal runtime messages that belong to the active stream reference only. This means unrelated mailbox messages in the caller process are preserved, so execute/2 can be used safely from OTP processes that also handle other messages.
When a stream finishes (result, timeout, parse error, or transport error), cleanup drains any remaining internal runtime messages for that session reference. This avoids leaving stale stream events in long-lived caller mailboxes.
execute/2 accepts either:
- a string prompt (
--stream-json/--stream-json-thinking) - a list of
UserInputMessagevalues (--stream-json-input)
The public stream message structs are schema-backed. Known fields are
normalized through Zoi, forward-compatible unknown fields are preserved in
extra, and the message/content modules expose to_map/1 for projection back
to wire shape.
Your App → AmpSdk.execute/2 → AmpSdk.Stream → AmpSdk.Runtime.CLI → cli_subprocess_core → amp CLI
(Stream.resource) (session kit) (shared runtime) (subprocess)Message Types
Messages are yielded in this order during a typical execution:
1. SystemMessage
Emitted once when the provider session is established.
%AmpSdk.Types.SystemMessage{
type: "system",
subtype: "init",
session_id: "T-abc123...",
cwd: "/path/to/project",
tools: ["Bash", "Read", "edit_file", ...],
mcp_servers: [%{name: "fs", status: "connected"}]
}2. AssistantMessage
The agent's response — may contain text and/or tool calls.
%AmpSdk.Types.AssistantMessage{
type: "assistant",
session_id: "T-abc123...",
message: %{
role: "assistant",
content: [
%AmpSdk.Types.TextContent{type: "text", text: "Here's what I found..."},
%AmpSdk.Types.ToolUseContent{type: "tool_use", id: "tu_1", name: "Bash", input: %{"command" => "ls"}}
],
stop_reason: "end_turn", # or "tool_use"
usage: %AmpSdk.Types.Usage{input_tokens: 100, output_tokens: 50}
}
}Thinking Content (with Options.thinking: true)
When thinking: true is set, assistant messages may contain ThinkingContent blocks:
%AmpSdk.Types.ThinkingContent{type: "thinking", thinking: "Let me work through this..."}These appear before text content in the content list and represent the model's chain-of-thought reasoning.
3. UserMessage
Tool results fed back to the agent (generated by the CLI, not by you).
%AmpSdk.Types.UserMessage{
type: "user",
message: %{
role: "user",
content: [
%AmpSdk.Types.ToolResultContent{tool_use_id: "tu_1", content: "file.txt\nREADME.md"}
]
}
}4. ResultMessage / ErrorResultMessage
Final outcome — the stream halts after this.
%AmpSdk.Types.ResultMessage{
result: "The project has 12 modules.",
duration_ms: 3500,
num_turns: 2,
is_error: false
}
%AmpSdk.Types.ErrorResultMessage{
error: "Permission denied for tool Bash",
duration_ms: 100,
num_turns: 0,
is_error: true,
permission_denials: ["Bash"]
}Patterns
Collect Final Result Only
{:ok, result} = AmpSdk.run("What is 2+2?")Typewriter Effect
AmpSdk.execute("Write a poem")
|> Enum.each(fn
%AmpSdk.Types.AssistantMessage{message: %{content: content}} ->
for %AmpSdk.Types.TextContent{text: text} <- content, do: IO.write(text)
%AmpSdk.Types.ResultMessage{} ->
IO.puts("\n--- done ---")
_ -> :ok
end)Accumulate with Metadata
result =
AmpSdk.execute("Analyze this code")
|> Enum.reduce(%{text: "", turns: 0, ms: 0}, fn
%AmpSdk.Types.AssistantMessage{message: %{content: content}}, acc ->
text = content |> Enum.filter(&match?(%{type: "text"}, &1)) |> Enum.map(& &1.text) |> Enum.join()
%{acc | text: acc.text <> text}
%AmpSdk.Types.ResultMessage{num_turns: t, duration_ms: ms}, acc ->
%{acc | turns: t, ms: ms}
_, acc -> acc
end)Filter by Message Type
tool_calls =
AmpSdk.execute("Fix this bug")
|> Stream.filter(&match?(%AmpSdk.Types.AssistantMessage{}, &1))
|> Stream.flat_map(fn %{message: %{content: content}} ->
Enum.filter(content, &match?(%AmpSdk.Types.ToolUseContent{}, &1))
end)
|> Enum.to_list()Timeouts
Set stream timeout directly via Options.stream_timeout_ms:
alias AmpSdk.Types.Options
AmpSdk.execute("Long running task", %Options{stream_timeout_ms: 30_000})
|> Enum.to_list()For outer cancellation boundaries, you can still wrap with Task:
task = Task.async(fn ->
AmpSdk.execute("Long running task") |> Enum.to_list()
end)
case Task.yield(task, 30_000) || Task.shutdown(task) do
{:ok, messages} -> handle_messages(messages)
nil -> IO.puts("Timed out")
end