Erlang ADK v0.7.0

Erlang ADK is an experimental, Erlang-native toolkit for building Gemini-backed agents with OTP processes, supervision, tools, sessions, event streams, and concurrent multi-agent workflows.

Version 0.7.0 is the cumulative Erlang ADK release from the v0.3-v0.7 delivery branches. It adds multimodal and Gemini Live sessions, Runner-global plugins, evaluation, expanded observability, and an authenticated production Phoenix companion on top of the agent, workflow, artifact, memory, context, authentication, MCP, and A2A foundations. The v0.7.0 release contract records both completed behavior and explicit limitations. The final deterministic, packaging, Phoenix, browser, dependency, and provider evidence is recorded in Verification and Testing; billable REST and Live Gemini suites remain explicit opt-in gates in a shell that owns GEMINI_API_KEY.

See the documentation index, changelog, upgrade guide, and security policy before a production rollout.

The detailed ADK behavior-parity matrix maps the official ADK capability families to their Erlang/OTP-native implementation contracts and current branch status.

The project follows the behavior of Google ADK where that behavior maps cleanly to Erlang. It is not a drop-in port and does not claim complete Google ADK feature parity. One agent is one gen_server admission point and one immutable runtime specification. Legacy prompt/delegate turns share a stateful FIFO; Runner and explicit invocation turns use bounded, fair, session-scoped lanes whose provider/tool work runs in supervised lightweight processes. Independent sessions, agents, and workflow branches therefore use BEAM concurrency without making one conversation nondeterministic.

Current scope

Areav0.7.0 status
Gemini REST and LiveREST text, versioned multimodal content, function calling, Google Search grounding, thinking levels/summaries, adjustable safety settings, thought signatures, call IDs, SSE text/content streaming, structured-output settings, and provider capability discovery are implemented with gemini-3.1-flash-lite. A separately supervised, bidirectional Gemini Live WebSocket runtime is implemented for gemini-3.1-flash-live-preview, including text/audio/image input, audio output, transcription, interruption, resumption, bounded backpressure, and explicitly allowlisted tools. Live sessions and media are intentionally not represented as REST SSE runs.
Erlang tools and agents exposed as model toolsCompiled schemas, strict arguments, collision checks, isolated AgentTool calls, and Runner boolean confirmation are implemented; direct/workflow confirmation fails closed because those surfaces have no approval continuation
Supervised sequential, bounded parallel, loop, transfer, and graph workflows with deadlines, budgets, cancellation, checkpoints, and resumeOutput propagation, explicit stop, schemas, action retry/timeout, graph/fork/sequential nested resume, and legacy helpers are implemented; nested pauses in top-level parallel branches, loop bodies, and transfer members are not resumable
Provider-neutral explicit planning and Gemini model-native thinkingVersioned JSON-safe plans, trusted planner/executor adapters, bounded replanning, monitored callbacks, owner-bound cancellation, and Gemini thinking levels/summaries are implemented; model-generated source is never executed
ETS/Mnesia sessions with scoped state, HMAC snapshot pagination, filters, and non-destructive branch/rewindThe v0.3.0 core is implemented and release-gated; schema-migration and configurable conflict-policy adapters are not claimed
Versioned JSON-safe events and human approval pause/resumeDurable single-use Runner/stable-run boolean approve/reject plus long-running/credential pauses are implemented; structured argument modification is not
Supervised stable runs with status, await, credit/ack subscribe/replay, cancellation, and retentionImplemented and release-gated on this branch
Ambient/background invocationSupervised local/event and fixed-delay schedule triggers use bounded concurrency, bounded queues, idempotency, deadlines, retry, stable status/await/cancel, and explicit per-event/shared/session-supplied policies; Pub/Sub/Eventarc remain application adapters
Bounded supervised tasks and serial/parallel-safe tool executionImplemented for compiled Erlang/OpenAPI/MCP catalogs in direct agents and Runner; descriptors are immutable snapshots and running agents do not support live catalog swap
Admission control and runtime policySupervised global/per-agent limits, monitored reject/bounded-FIFO queue policies, fail-closed agent/tool allow-deny rules, byte budgets, and immutable denial audit events are implemented on this branch
Agent/model/tool callbacks and Runner-global pluginsOrdered Runner plugins, explicit amend-versus-early-return behavior, phase-specific error notification, bounded stateful plugin actors, built-in global-instruction/context-filter/reflect-retry/metadata plugins, and existing local callbacks are implemented; see lifecycle notes below
ObservabilityActual model/tool/Live operation spans, strict W3C Trace Context, a pinned metadata-only GenAI semantic mapping, bounded low-cardinality metrics, synchronous or supervised asynchronous delivery, and OTLP/HTTP JSON export are implemented. Prompt, response, media, arguments, and results remain excluded by default.
EvaluationLegacy lightweight evaluation remains. Eval-set/result schema v2 adds full-case response and trajectory criteria, an explicit bounded Gemini rubric judge, repeat sampling, isolated per-case agent lifecycle, strict accounting, baseline comparison, stable JSON/Markdown reports, and adk eval run CI exit semantics.
OpenAPIStrict OpenAPI 3.0/3.1 compiler, production Gun transport, per-principal auth broker, and first-class agent/Runner toolsets are implemented; the supported subset is documented below
MCPSupervised stdio and MCP 2025-11-25 Streamable HTTP clients plus a bounded tool/resource/prompt server are implemented and release-gated; optional server GET/SSE and advanced capabilities remain explicit limitations
A2A interoperabilityA2A 1.0 Agent Card plus bounded JSON-RPC/SSE tasks, artifacts, replay, principal scoping, and outbound client are implemented; the older /a2a/prompt endpoint remains explicitly legacy and is startup-enforced loopback-only
Versioned artifactsPartial 0.5 implementation: strict app/user/session scopes, immutable ETS/filesystem versions, quotas, paginated metadata, deadline-aware mutations, least-authority tool helpers, metadata-only event effects, one-request model-selected attachment, filesystem repair, exact-scope developer inspection/delete, and an opt-in bounded exact-scope sharded adapter are implemented; direct adapters serialize per service while sharded workers preserve same-scope ordering and let unrelated scopes overlap; durable lifetime scope/name/version admission fails explicitly before bounded scans can be exhausted; credit-based blob streaming and complete durable orphan recovery remain open
Scoped long-term memoryPartial 0.5 implementation: the v2 app/user-scoped contract, bounded lexical ETS and durable local Mnesia adapters, provenance/idempotency, preload and model-selected retrieval, entry/session/user erasure, deadline-aware calls, fail-closed Mnesia-outbox admission, developer search/erase, and an opt-in bounded exact-user sharded adapter are implemented; durable delivery uses bounded resolution and a freshly revalidated ownership lease immediately before an idempotent at-least-once mutation; direct lexical adapters serialize per service while sharded workers overlap unrelated users; managed vector search, pending-job erasure coordination, and policy-driven retention remain adapters or application policy
Context selection, compaction, and cachingPartial 0.5 implementation: mandatory model-boundary sanitization, complete-envelope budgets, O(n) exchange-aware selection, owner-bound compression, context fingerprints, opt-in Runner compaction with atomic ETS/Mnesia checkpoint persistence, a provider-prefix-cache lifecycle, and deterministic Gemini create/reuse/bypass/generate/stream wiring are implemented; cache installation synchronously rechecks every absolute waiter deadline and deletes an orphan provider resource when no live waiter remains; caching is request-prefix reuse rather than response caching, and billable Gemini REST cache evidence remains a separate gate
Auth and integrated developer toolingImmutable provider profiles, bounded single-flight OAuth tokens, supervised authorization-code + S256 PKCE, strict OIDC/JWT policy, default-deny operation scopes, issuer-bound run ownership, and loopback-only /dev tooling are implemented. v0.7 adds authenticated Live status/text/SSE, evaluation render/compare, and observability snapshots; the CLI adds Live/observability controls and eval run. /dev remains local single-operator administration, not an end-user API.
Phoenix LiveView companionThe checked Phoenix 1.8/LiveView application uses OIDC code+PKCE and opaque server-side state. Its server-owned, exact-scope gateways cover stable runs and v0.7 Live/evaluation/observability views with bounded projections; it is an optional same-BEAM production companion, not a core Erlang dependency. A separate authenticated binary voice socket uses the core owner-bound bridge for bounded 16 kHz microphone input, native audio playback, per-event credit, and interruption; LiveView assigns remain media-free and Live reconnect never claims replay.

Source layout

The Erlang module namespace remains flat, but implementation files are grouped by ownership under one recursive src root. The root contains only the public facade and OTP application shell. Agents, tools, workflows, Live, runtime, context, artifacts, memory, sessions, protocols, integrations, authentication, models, plugins, telemetry, and evaluation each have explicit ownership directories; provider implementations such as Gemini remain below models/. This is a filesystem-only organization: public module atoms and BEAM names do not change. See src/README.md for the exact boundaries and rules for new modules. Test and fixture modules mirror the same ownership hierarchy under a test-profile-only recursive test root; see docs/TEST_LAYOUT.md for placement and discovery rules.

Installation

The supported release toolchain is Erlang/OTP 27; v0.7.0 was verified with OTP 27.3.4.14 and the bundled Rebar3 3.27.0. Older OTP 27 patch levels are not the supported production baseline because 27.3.4.14 contains required TLS security fixes. The core library does not require Elixir or Node.js. The optional Phoenix companion has its own pinned toolchain in examples/phoenix_adk_ui/.tool-versions.

After the release owner creates v0.7.0, use that immutable tag. Before the tag exists, pin the reviewed release-candidate commit SHA instead of a mutable branch. After the package is published to Hex, the equivalent package requirement is {erlang_adk, "0.7.0"}.

{deps, [
{erlang_adk,
{git, "https://github.com/hsalap7/erlang_adk.git",
{tag, "v0.7.0"}}}
]}.

Start the application before spawning agents:

{ok, _StartedApps} = application:ensure_all_started(erlang_adk).

Gemini calls read GEMINI_API_KEY, or you can put api_key in the provider config. The environment variable avoids keeping a secret in source code:

export GEMINI_API_KEY="your_api_key_here"

The default model is the stable Gemini 3.1 Flash-Lite, gemini-3.1-flash-lite.

Quickstart

The repository includes the tool used below. From ./rebar3 shell:

{ok, readme_weather_tool} = c("examples/readme_weather_tool.erl"),
LLMConfig = #{
provider => adk_llm_gemini,
instructions => <<"You are a concise weather assistant.">>,
model => <<"gemini-3.1-flash-lite">>
},
{ok, WeatherPid} = erlang_adk:spawn_agent(
<<"WeatherAgent">>, LLMConfig, [readme_weather_tool]),
{ok, WeatherResponse} = erlang_adk:prompt(
WeatherPid, <<"What is the weather in Tokyo?">>),
io:format("~ts~n", [WeatherResponse]),
ok = erlang_adk:stop_agent(WeatherPid).

Provider-reported failures use {error, Reason}. A missing key, for example, returns {error, missing_api_key} rather than a successful error string.

Provider adapters advertise only behavior they actually implement:

{ok, GeminiCapabilities} = adk_llm:capabilities(adk_llm_gemini),
true = maps:get(streaming, GeminiCapabilities),
true = maps:get(function_calling, GeminiCapabilities),
true = maps:get(structured_output, GeminiCapabilities),
true = maps:get(multimodal, GeminiCapabilities),
true = maps:get(thinking, GeminiCapabilities),
true = maps:get(safety_settings, GeminiCapabilities),
true = maps:get(google_search_grounding, GeminiCapabilities),
[google_search] = maps:get(builtin_tools, GeminiCapabilities),
1 = maps:get(content_schema_version, GeminiCapabilities),
false = maps:get(live, GeminiCapabilities),
ok = adk_llm:validate_config(
#{provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>,
response_mime_type => <<"application/json">>,
response_schema => #{<<"type">> => <<"object">>}}).

Custom providers need only generate/3 and stream/4; capabilities/0 and validate_config/1 are optional behavior callbacks.

Google Search grounding

Gemini 3.1 Flash-Lite can let the model ground an answer with Google Search by adding the strictly validated built-in tool. This uses the Gemini generateContent/streamGenerateContentgoogleSearch tool, not the Interactions API:

GroundingConfig = #{
provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>,
builtin_tools => [google_search]
},
GroundingHistory = [
#{role => user,
content => <<"What changed in the latest stable Erlang/OTP release?">>}
],
case adk_llm:generate(GroundingConfig, GroundingHistory, []) of
{provider_result, _} = GroundedResult ->
{ok, {ok, GroundedAnswer}, ProviderMetadata} =
adk_provider_result:decode(GroundedResult),
<<"gemini">> = maps:get(<<"provider">>, ProviderMetadata),
<<"google_search_grounding">> =
maps:get(<<"type">>, ProviderMetadata),
GroundingMetadata = maps:get(<<"metadata">>, ProviderMetadata),
io:format("~ts~nqueries: ~p~n", [
GroundedAnswer,
maps:get(<<"webSearchQueries">>, GroundingMetadata, [])
]);
{ok, UngroundedAnswer} ->
%% Enabling Search lets Gemini decide whether the prompt needs it.
io:format("~ts~n", [UngroundedAnswer])
end.

When both Erlang/custom tools and Google Search are configured, the request contains one googleSearch declaration and one functionDeclarations declaration; the two mechanisms are not conflated. A candidate with no groundingMetadata keeps the existing provider return byte-for-byte compatible. A grounded candidate uses the reserved {provider_result, Envelope} result. Agents unwrap its model output before output-schema validation. Runner/event APIs persist this JSON-safe projection:

#{<<"provider_metadata">> =>
#{<<"schema_version">> => 1,
<<"provider">> => <<"gemini">>,
<<"type">> => <<"google_search_grounding">>,
<<"metadata">> => GroundingMetadata}}

The projection is attached to the event whose Gemini candidate carried it, including tool-call events. SSE list fields are accumulated in arrival order, and the final streamed event receives the combined metadata. Candidate metadata must be a strict JSON map and is capped at 256 KiB; malformed or oversized values fail explicitly instead of being dropped. Direct prompt/2 intentionally returns only the answer, so use Runner/event APIs or direct adk_llm calls when citations or grounding provenance must be retained.

searchEntryPoint.renderedContent can contain provider-supplied HTML. Erlang ADK preserves it as bounded data but never renders it as HTML. The bundled developer console uses textContent/JSON rendering. A custom UI must sanitize untrusted markup and satisfy Google's display terms before showing search suggestions. This release does not add URL Context, Maps grounding, Enterprise Web Search, Gemini Live grounding, automatic citation insertion, or an Interactions-API adapter. See Gemini Google Search grounding, the official Google Search grounding guide, and the GenerateContent API.

Gemini 3.1 Flash-Lite supports model-native thinking. Direct provider calls put the validated option at the provider-config level; an agent puts the same map inside generation_config:

ThinkingConfig = #{
provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>,
thinking_config => #{thinking_level => high,
include_thoughts => true}
},
{ok, ThinkingResult} = adk_llm:generate(
ThinkingConfig,
[#{role => user,
content => <<"Explain why supervisors isolate failures.">>}], []),
case ThinkingResult of
Answer when is_binary(Answer) -> io:format("~ts~n", [Answer]);
ThoughtContent when is_map(ThoughtContent) ->
{ok, ThoughtContent} = adk_content:validate(ThoughtContent),
io:format("structured response: ~p~n", [ThoughtContent])
end.

Supported levels are minimal, low, medium, and high; omission uses the model default. minimal is not a guarantee that thinking is disabled. The legacy numeric thinking_budget is accepted for older Gemini models, but the adapter rejects a request containing both a level and a budget. When include_thoughts is true and Gemini returns an optional thought summary, the one-shot result is canonical adk_content so the summary marked <<"thought">> => true cannot be concatenated into the visible answer. Text streaming similarly emits only visible answer deltas; content streaming preserves both kinds of part. Thought summaries are provider output, not an executable adk_plan.

Gemini safety settings are request-scoped. Direct provider calls put safety_settings at the provider-config level; agents put the same list in generation_config:

SafetySettings = [
#{category => hate_speech, threshold => block_low_and_above},
#{category => harassment, threshold => block_only_high}
],
ok = adk_llm:validate_config(
#{provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>,
safety_settings => SafetySettings}).

The adjustable categories are harassment, hate_speech, sexually_explicit, and dangerous_content. Thresholds are off, block_none, block_only_high, block_medium_and_above, block_low_and_above, and unspecified; they encode to Gemini's documented safetySettings REST enums. Categories must be unique. The deprecated civic integrity category, unknown keys, and unknown thresholds fail locally before HTTP. These settings adjust probability filters; they do not disable Gemini's built-in protection for core harms. Applications remain responsible for testing and choosing an appropriate policy. See the official Gemini safety settings and GenerateContent request contracts.

Gemini configuration is strict: a misspelled or unsupported top-level key, or an unknown key inside generation_config, returns an explicit validation error instead of being silently ignored. Documented agent/runtime fields are accepted because the immutable agent config is shared with the provider adapter. Callback-specific application data belongs under callback_config; callbacks receive a least-privilege projection containing only model/generation metadata and that recursively pruned namespace. API keys, credentials, process IDs, references, clients, connections, and store handles are never included. Register observer processes out of band, as in the callback example below.

Gemini provider configs may set request_timeout to a non-negative number of milliseconds (or infinity). It bounds the complete non-streaming HTTP request and each streaming connection/response/data wait. Omitting it keeps the underlying httpc and Gun defaults. When calling Gemini through an agent, keep this below the separate agent_call_timeout and agent_turn_timeout application settings (both 60 seconds by default). request_timeout => infinity removes only the transport limit; direct adk_llm calls do not have the outer agent deadline.

Text returned by prompt/2, correlated delegation, orchestration helpers, Runner, agent-as-tool, and the HTTP client is a UTF-8 binary. Use ~ts to print it as Unicode text; unlike a legacy Erlang charlist, it will not appear as a list of integer code points in the shell or logs.

Agent contracts and scoped instructions

An agent validates immutable input/output contracts before and after the model call. Instructions can read the exact invocation's scoped state, structured output is requested from a capable provider, and output_key is committed in the same final session event as the response:

ContractApp = <<"contract_app">>,
ContractUser = <<"user-1">>,
ContractSession = <<"contract-session">>,
ok = erlang_adk_session:init(),
{ok, _} = erlang_adk_session:create_session(
ContractApp, ContractUser,
#{session_id => ContractSession,
state => #{<<"user:name">> => <<"Ada">>}}),
ContractConfig = #{
provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>,
instructions =>
<<"Answer {user:name} concisely using the requested JSON shape.">>,
input_schema => #{
type => object,
properties => #{<<"topic">> => #{type => string, minLength => 1}},
required => [<<"topic">>],
additionalProperties => false},
output_schema => #{
type => object,
properties => #{<<"answer">> => #{type => string, minLength => 1}},
required => [<<"answer">>],
additionalProperties => false},
output_key => <<"user:last_answer">>,
history_policy => exclude,
generation_config => #{
max_output_tokens => 128,
thinking_config => #{thinking_level => low},
safety_settings =>
[#{category => harassment,
threshold => block_medium_and_above}]}
},
{ok, ContractAgent} = erlang_adk:spawn_agent(
<<"ContractAgent">>, ContractConfig, []),
ContractRunner = adk_runner:new(
ContractAgent, ContractApp, erlang_adk_session),
ContractInput = jsx:encode(#{<<"topic">> => <<"supervision trees">>}),
{ok, ContractResponse} = adk_runner:run(
ContractRunner, ContractUser, ContractSession, ContractInput),
#{<<"answer">> := ContractAnswer} =
jsx:decode(ContractResponse, [return_maps]),
io:format("~ts~n", [ContractAnswer]),
{ok, ContractStored} = erlang_adk_session:get_session(
ContractApp, ContractUser, ContractSession),
#{<<"user:last_answer">> := #{<<"answer">> := ContractAnswer}} =
maps:get(state, ContractStored),
ok = erlang_adk:stop_agent(ContractAgent),
ok = erlang_adk_session:delete_session(
ContractApp, ContractUser, ContractSession).

global_instruction accepts the same static templates and {dynamic, Module, Function} providers as instructions. For a direct or Runner invocation, that agent is the root: its global instruction is prepended to its local instruction, and the same root instruction provider is carried across process boundaries and resolved for each delegated sub-agent against that invocation's scope. A child's own global_instruction is ignored while it runs below that root, but applies if the child is invoked independently as the root of a new tree. Resolution remains bounded and secret-scrubbed; agent configuration is never mutated.

Google's Python ADK currently marks its direct global_instruction field as deprecated in favor of GlobalInstructionPlugin. Erlang ADK retains the explicit root field for tree-scoped compatibility and provides adk_plugin_global_instruction for ordered Runner-wide policy. The plugin amends the checked model request through the normal v0.7 plugin pipeline; it does not mutate agent configuration or replace the root/delegation contract.

history_policy => exclude removes earlier turns but always retains the current input. A schema failure is returned before provider execution or state mutation. Static placeholders support scoped state keys such as {user:name} and exact-scope artifacts such as {artifact.reports/summary.txt}; secret-like keys are rejected. Dynamic instruction providers use {dynamic, Module, Function} and receive only a bounded, read-only scope map.

Tools

A tool module implements adk_tool and returns {ok, Value} or {error, Reason} from execute/2:

-module(readme_weather_tool).
-behaviour(adk_tool).
-export([schema/0, execute/2]).
schema() ->
#{<<"name">> => <<"get_weather">>,
<<"description">> => <<"Get the weather for a city">>,
<<"parameters">> =>
#{<<"type">> => <<"object">>,
<<"properties">> =>
#{<<"city">> => #{<<"type">> => <<"string">>}},
<<"required">> => [<<"city">>]}}.
execute(#{<<"city">> := City}, Context) ->
_ = Context,
{ok, #{<<"city">> => City, <<"forecast">> => <<"sunny">>}}.

For agent-driven calls, Context contains app_name, session_id, user_id, invocation_id, call_id, and state_ref when available. Tool exceptions and {error, Reason} results are returned to the model as failed function responses instead of terminating the agent.

Before the provider sees a catalog, Erlang ADK loads every module/toolset schema, normalizes and compiles its supported JSON Schema, and rejects invalid schemas or duplicate names across Erlang tools, dynamic toolsets, and sub-agents. The whole provider call batch is structurally validated before it is persisted. Each call's arguments are then checked against the compiled schema before runtime policy, confirmation, callbacks, credentials, or side effects. Invalid values are never reflected in the model-visible structural error.

Gemini has two mutually exclusive function-parameter fields. Erlang ADK keeps the normalized provider-neutral contract for local validation, then emits the legacy parameters field only for a deliberately small positive subset that Gemini accepts there. Any schema outside that subset—including oneOf, additionalProperties, a type union, a boolean subschema, a top-level parameters => true | false schema, or an unknown keyword—is sent unchanged as parametersJsonSchema. The provider boundary does not weaken the tool's runtime validation contract.

An adk_toolset descriptor is an immutable catalog snapshot. schemas/1 and resolution reuse its compiled contracts; refresh/1 deliberately returns a replacement descriptor for a mutable MCP/OpenAPI backend. A running agent has no live catalog-swap API, so new remote tools require a refreshed descriptor and replacement agent. A removed or changed operation fails closed as tool_catalog_changed instead of executing against a stale declaration. Trusted local Erlang tool contexts carry private bounded agent ancestry so an AgentTool wrapper cannot reset cycle protection; that metadata is not sent to remote toolsets or model providers.

External-sandbox code execution

adk_code_toolset makes a checked execute_code tool available only through an application-supplied adk_code_executor. Erlang ADK deliberately has no erl_eval, shell, local-interpreter, executable-path, port, or NIF fallback:

#{external_sandbox_required := true,
in_process_execution := false,
shell_fallback := false} = adk_code_toolset:capabilities(),
{ok, CodeTools} = adk_code_toolset:new(
#{executor => {my_sandbox_adapter, SandboxHandle},
languages => [<<"erlang">>, <<"python">>],
timeout => 30000,
max_code_bytes => 65536,
max_output_bytes => 1048576,
parallel_safe => false}),
{ok, CodeAgent} = erlang_adk:spawn_agent(
<<"SandboxedCoder">>,
#{provider => adk_llm_gemini,
instructions =>
<<"Use execute_code only when calculation requires it.">>},
[CodeTools]).

The configuration fragment above becomes runnable after the application provides my_sandbox_adapter and SandboxHandle; they are intentionally not invented by the library. Before invoking that adapter, the toolset enforces a language allow-list, UTF-8/input/file/output limits, canonical relative paths, a minimal redacted context, and the normal supervised tool deadline, plugin, policy, and cancellation path. The external service must independently enforce CPU, memory, process, filesystem, network, and cleanup isolation. See External sandbox code execution for the adapter behaviour and deployment requirements.

OpenAPI toolsets

OpenAPI operations use the same tool path as Erlang modules. The compiler accepts an already-decoded OpenAPI 3.0 or 3.1 document, generates deterministic model schemas, and resolves each selected operation into a bounded worker. The repository includes the schema used here:

{ok, PetstoreJson} = file:read_file(
"examples/readme_petstore_openapi.json"),
PetstoreSpec = jsx:decode(PetstoreJson, [return_maps]),
{ok, Petstore} = adk_openapi_toolset:compile(
PetstoreSpec,
#{transport => {adk_openapi_gun_transport, default},
allowed_hosts => [<<"petstore3.swagger.io">>]}),
{ok, PetstoreTools} = adk_toolset:new(
adk_openapi_toolset, Petstore),
{ok, PetstoreAgent} = erlang_adk:spawn_agent(
<<"PetstoreAgent">>,
#{provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>,
instructions => <<"Use the Petstore operation when needed.">>},
[PetstoreTools]),
{ok, PetstoreAnswer} = erlang_adk:prompt(
PetstoreAgent, <<"What is the name and status of pet 1?">>),
io:format("~ts~n", [PetstoreAnswer]),
ok = erlang_adk:stop_agent(PetstoreAgent).

Production transport defaults to HTTPS, re-resolves and validates the target against allowed_hosts, rejects private addresses and redirects, verifies TLS hostnames, and enforces request, response, operation, parameter, schema-depth, and time limits. Set allow_private_hosts => true and explicitly allow <<"http">> only for a trusted local service.

The supported request subset is path/query/header parameters plus JSON request bodies and JSON responses. Local $ref values are resolved. API keys in headers or query parameters, HTTP bearer, and OAuth 2.0 are resolved out of band through adk_openapi_auth_broker; credentials are never model arguments. Cookie parameters or API keys, Basic/Digest auth, mTLS, OpenID Connect security schemes, form/multipart bodies, callbacks, and remote references are rejected explicitly rather than silently weakened.

Asynchronous delegation

Use a reference when several delegated requests may be in flight:

AsyncConfig = #{provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>},
{ok, AsyncPid} = erlang_adk:spawn_agent(
<<"AsyncAgent">>, AsyncConfig, []),
Ref = make_ref(),
ok = erlang_adk:delegate(
AsyncPid, <<"Summarize OTP in one sentence.">>, self(), Ref),
receive
{agent_response, Ref, AsyncPid, {ok, AsyncResponse}} ->
io:format("~ts~n", [AsyncResponse]);
{agent_response, Ref, AsyncPid, {error, AsyncReason}} ->
io:format("delegation failed: ~p~n", [AsyncReason])
after 60000 ->
exit(delegate_timeout)
end,
ok = erlang_adk:stop_agent(AsyncPid).

delegate/2 is fire-and-forget. The older delegate/3 message format remains supported, but delegate/4 is the safe choice for correlation.

Concurrent multi-agent workflows

Sequential execution feeds each response into the next agent. Parallel execution creates monitored Erlang workers, preserves input order, and applies one overall timeout:

{ok, TranslatorPid} = erlang_adk:spawn_agent(
<<"Translator">>,
#{provider => adk_llm_gemini,
instructions => <<"Translate the input to French.">>}, []),
{ok, SummarizerPid} = erlang_adk:spawn_agent(
<<"Summarizer">>,
#{provider => adk_llm_gemini,
instructions => <<"Summarize the input in one sentence.">>}, []),
{ok, PipelineResult} = erlang_adk:sequential(
[TranslatorPid, SummarizerPid], <<"Erlang processes are lightweight.">>),
ParallelResults = erlang_adk:parallel(
[TranslatorPid, SummarizerPid], <<"Explain OTP.">>, 60000),
io:format("pipeline=~ts parallel=~p~n",
[PipelineResult, ParallelResults]),
ok = erlang_adk:stop_agent(TranslatorPid),
ok = erlang_adk:stop_agent(SummarizerPid).

Each parallel result is {AgentPid, Response} or {AgentPid, {error, Reason}}. Requests to the same agent PID retain FIFO turn semantics; their blocking work is mailbox-isolated but not executed concurrently. Concurrency comes from independent agents.

The bounded worker/reviewer loop treats a response as approval only when, after trimming and case normalization, it is exactly APPROVED. It also stops at the iteration bound and then returns the latest draft, even if the reviewer has not approved it:

{ok, WriterPid} = erlang_adk:spawn_agent(
<<"Writer">>,
#{provider => adk_llm_gemini,
instructions => <<"Write and revise the requested draft.">>}, []),
{ok, ReviewerPid} = erlang_adk:spawn_agent(
<<"Reviewer">>,
#{provider => adk_llm_gemini,
instructions => <<"Reply exactly APPROVED when the draft is ready; otherwise critique it.">>}, []),
{ok, FinalDraft} = erlang_adk:loop(
WriterPid, ReviewerPid, <<"Write a short poem about Erlang.">>, 3),
io:format("~ts~n", [FinalDraft]),
ok = erlang_adk:stop_agent(WriterPid),
ok = erlang_adk:stop_agent(ReviewerPid).

Sub-agents and agent-as-tool

Sub-agents are advertised to the model as function declarations. A description helps the coordinator route well:

{ok, SearchPid} = erlang_adk:spawn_agent(
<<"SearchSpecialist">>,
#{provider => adk_llm_gemini,
instructions => <<"Research the requested Erlang topic.">>}, []),
CoordinatorConfig = #{
provider => adk_llm_gemini,
global_instruction =>
<<"You are part of the Erlang documentation team.">>,
instructions => <<"Delegate research tasks to SearchSpecialist.">>,
sub_agents => #{
<<"SearchSpecialist">> =>
#{pid => SearchPid,
description => <<"Researches Erlang and OTP topics">>}
}
},
{ok, CoordinatorPid} = erlang_adk:spawn_agent(
<<"Coordinator">>, CoordinatorConfig, []),
{ok, CoordinatedResponse} = erlang_adk:prompt(
CoordinatorPid, <<"Research supervision trees.">>),
io:format("~ts~n", [CoordinatedResponse]),
AgentToolSchema = adk_agent_tool:schema(
#{name => <<"SearchSpecialist">>,
description => <<"Researches Erlang and OTP topics">>}),
{ok, DirectSpecialistResponse} = adk_agent_tool:execute(
SearchPid, #{<<"prompt">> => <<"Explain rest_for_one.">>}, #{}),
io:format("schema=~p response=~ts~n",
[AgentToolSchema, DirectSpecialistResponse]),
ok = erlang_adk:stop_agent(CoordinatorPid),
ok = erlang_adk:stop_agent(SearchPid).

The coordinator resolves a restarted sub-agent by its registered binary name, so a stale child PID does not have to crash the parent agent. Agent names use the portable [A-Za-z_][A-Za-z0-9_]* grammar; user is reserved. Spawn-time tree validation rejects alias/name mismatches, duplicate ownership, self-reference, cycles, more than 256 agents, depth above 64, and an unresponsive tree after one two-second deadline. Invocation-time ancestry tracking independently rejects a repeated agent or depth above 64 before its provider runs, including dynamically resolved replacements.

prompt/2 and asynchronous delegate retain the compatibility conversation and execute in one FIFO. erlang_adk:invoke/3, AgentTool, and Runner use fresh invocation history. The same {app_name,user_id,session_id} lane remains FIFO; different lanes may overlap up to max_concurrent_invocations (32 by default) with fair admission. A child owns its provider, model, local instructions, and tool catalog. Only the root global instruction and explicitly scoped safe context cross a delegation boundary—never compatibility memory or provider credentials. The safe scope includes the caller's state snapshot and opaque session-service module (state_ref) so an output_key commits to the exact invocation session rather than the reusable agent's configured default; AgentTool may additionally carry the explicitly configured memory-service reference.

Supervised declarative workflows

adk_workflow executes each accepted workflow under adk_workflow_sup. Potentially blocking actions run in monitored, linked lightweight processes; one absolute deadline covers the entire workflow. State is JSON-safe and each action returns a state delta rather than mutating shared state.

Each coordinator child specification contains only an opaque launch reference. Compiled closures, initial state, options, checkpoints, ledger handles and owner tokens cross a validated one-shot handoff and are omitted from OTP diagnostics. Compound action, cancellation, engine and ledger failures become bounded structural adk_failure values before they reach public status or durable storage; stable atom reasons remain compatible.

Sequential and bounded-parallel workflows use the same compiled public API:

AddOne = fun(State, Context) ->
null = maps:get(input, Context),
{output, <<"counted">>,
#{<<"count">> => maps:get(<<"count">>, State, 0) + 1}}
end,
MarkDone = fun(_State, Context) ->
<<"counted">> = maps:get(input, Context),
{output, <<"ready">>, #{<<"done">> => true}}
end,
SequentialSpec = #{
version => 1,
id => <<"readme-sequential-v1">>,
kind => sequential,
max_steps => 4,
input_schema =>
#{<<"type">> => <<"object">>,
<<"required">> => [<<"count">>]},
output_schema => #{<<"type">> => <<"string">>},
steps => [
#{id => <<"increment">>, run => AddOne},
#{id => <<"finish">>, run => MarkDone}
]
},
{ok, Sequential} = erlang_adk:compile_workflow(SequentialSpec),
{completed, SequentialState, SequentialCheckpoint} =
erlang_adk:run_workflow(Sequential, #{<<"count">> => 0}),
1 = maps:get(<<"count">>, SequentialState),
true = maps:get(<<"done">>, SequentialState),
true = maps:get(<<"completed">>, SequentialCheckpoint),
<<"ready">> = maps:get(<<"output">>, SequentialCheckpoint),
ParallelSpec = #{
version => 1,
id => <<"readme-parallel-v1">>,
kind => parallel,
max_concurrency => 2,
merge => reject_conflicts,
branches => [
#{id => <<"left">>,
run => fun(_State) ->
{output, <<"left-output">>, #{<<"left">> => 1}}
end},
#{id => <<"right">>,
run => fun(_State) ->
{output, <<"right-output">>, #{<<"right">> => 2}}
end}
]
},
{ok, Parallel} = erlang_adk:compile_workflow(ParallelSpec),
{completed, ParallelState, ParallelCheckpoint} =
erlang_adk:run_workflow(Parallel, #{}),
1 = maps:get(<<"left">>, ParallelState),
2 = maps:get(<<"right">>, ParallelState),
#{<<"left">> := <<"left-output">>,
<<"right">> := <<"right-output">>} =
maps:get(<<"output">>, ParallelCheckpoint),
RetryTable = ets:new(readme_workflow_retry, [set, public]),
ets:insert(RetryTable, {attempts, 0}),
RetryAction = fun(_State, Context) ->
Attempt = ets:update_counter(RetryTable, attempts, 1),
Attempt = maps:get(attempt, Context),
case Attempt of
1 -> {error, transient_failure};
2 -> {output, <<"recovered">>, #{<<"retried">> => true}}
end
end,
RetrySpec = #{
version => 1,
id => <<"readme-workflow-retry-v1">>,
kind => sequential,
max_steps => 1,
steps => [
#{id => <<"retryable">>, run => RetryAction,
timeout => 1000,
retry => #{max_attempts => 2, backoff_ms => 10}}
]
},
{ok, Retrying} = erlang_adk:compile_workflow(RetrySpec),
{completed, #{<<"retried">> := true}, RetryCheckpoint} =
erlang_adk:run_workflow(Retrying, #{}),
<<"recovered">> = maps:get(<<"output">>, RetryCheckpoint),
true = ets:delete(RetryTable).

Parallel branches receive the same immutable input state. The default reject_conflicts merge rejects unequal writes to one key; use ordered_last_wins only when declared branch order should resolve conflicts. Completion order never changes merge order.

Loop, collaborative transfer, and dynamic graph routing are also first-class specifications:

LoopSpec = #{
version => 1,
id => <<"readme-loop-v1">>,
kind => loop,
max_iterations => 3,
body => fun(State) ->
Attempt = maps:get(<<"attempt">>, State, 0) + 1,
{output, Attempt, #{<<"attempt">> => Attempt}}
end,
until => fun(State) -> maps:get(<<"attempt">>, State) >= 2 end
},
{ok, Loop} = erlang_adk:compile_workflow(LoopSpec),
{completed, #{<<"attempt">> := 2}, LoopCheckpoint} =
erlang_adk:run_workflow(Loop, #{}),
2 = maps:get(<<"output">>, LoopCheckpoint),
TransferSpec = #{
version => 1,
id => <<"readme-transfer-v1">>,
kind => transfer,
entry => <<"triage">>,
max_transfers => 1,
members => #{
<<"triage">> => #{run => fun(_State, _Context) ->
{transfer, <<"specialist">>, <<"handoff">>,
#{<<"triaged">> => true}}
end},
<<"specialist">> => #{run => fun(State, Context) ->
true = maps:get(<<"triaged">>, State),
<<"handoff">> = maps:get(input, Context),
{stop, <<"resolved">>,
#{<<"resolved">> => true}}
end}
}
},
{ok, Transfer} = erlang_adk:compile_workflow(TransferSpec),
{completed, #{<<"resolved">> := true}, _} =
erlang_adk:run_workflow(Transfer, #{}),
GraphSpec = #{
version => 1,
id => <<"readme-graph-v1">>,
kind => graph,
entry => <<"counter">>,
max_steps => 5,
nodes => [#{id => <<"counter">>, run => fun(State) ->
Count = maps:get(<<"count">>, State, 0) + 1,
{output, Count, #{<<"count">> => Count}}
end}],
edges => #{<<"counter">> => {route, fun(_State, Context) ->
Count = maps:get(input, Context),
case Count < 3 of
true -> <<"counter">>;
false -> end_node
end
end}}
},
{ok, Graph} = erlang_adk:compile_workflow(GraphSpec),
{completed, #{<<"count">> := 3}, GraphCheckpoint} =
erlang_adk:run_workflow(Graph, #{}),
3 = maps:get(<<"output">>, GraphCheckpoint).

Graph-native fork/join executes declared branch node IDs in bounded lightweight processes and merges their deltas in declared order. A paused graph commits the node delta first; resuming supplies JSON-safe input to the node's edge router, so the paused node is not executed twice:

ForkJoinSpec = #{
version => 1, id => <<"readme-fork-join-v1">>, kind => graph,
entry => <<"fork">>, max_steps => 6,
nodes => [
#{id => <<"fork">>, type => fork,
branches => [<<"left">>, <<"right">>], join => <<"join">>,
merge => reject_conflicts, max_concurrency => 2},
#{id => <<"left">>, run => fun(_) ->
{output, <<"left-output">>, #{<<"left">> => 1}}
end},
#{id => <<"right">>, run => fun(_) ->
{output, <<"right-output">>, #{<<"right">> => 2}}
end},
#{id => <<"join">>, type => join,
run => fun(_State, Context) ->
Outputs = maps:get(input, Context),
<<"left-output">> = maps:get(<<"left">>, Outputs),
<<"right-output">> = maps:get(<<"right">>, Outputs),
{output, Outputs, #{<<"joined">> => true}}
end}
],
edges => #{<<"left">> => <<"join">>,
<<"right">> => <<"join">>,
<<"join">> => end_node}
},
{ok, ForkJoin} = erlang_adk:compile_workflow(ForkJoinSpec),
{completed, #{<<"left">> := 1, <<"right">> := 2,
<<"joined">> := true}, ForkCheckpoint} =
erlang_adk:run_workflow(ForkJoin, #{}),
#{<<"left">> := <<"left-output">>,
<<"right">> := <<"right-output">>} =
maps:get(<<"output">>, ForkCheckpoint),
ApprovalSpec = #{
version => 1, id => <<"readme-graph-approval-v1">>, kind => graph,
entry => <<"approval">>, max_steps => 3,
nodes => [
#{id => <<"approval">>, run => fun(_) ->
{pause, human_approval, <<"Approve this action">>,
#{<<"approval_requested">> => true}}
end},
#{id => <<"accepted">>, run => fun(_) ->
{ok, #{<<"accepted">> => true}}
end}
],
edges => #{
<<"approval">> => {route, fun(_State, Context) ->
true = maps:get(<<"approved">>, maps:get(input, Context)),
<<"accepted">>
end},
<<"accepted">> => end_node}
},
{ok, Approval} = erlang_adk:compile_workflow(ApprovalSpec),
{paused, _PauseDetails, ApprovalCheckpoint} =
erlang_adk:run_workflow(Approval, #{}),
{ok, ApprovalRef} = erlang_adk:resume_workflow(
Approval, ApprovalCheckpoint,
#{resume_input => #{<<"approved">> => true}}),
{completed, #{<<"approval_requested">> := true,
<<"accepted">> := true}, _} =
erlang_adk:await_workflow(ApprovalRef).

For recovery after the workflow coordinator, application, or BEAM restarts, use a stable invocation ID backed by the Mnesia ledger. The compiled workflow is application code and must be reconstructed with the same id, version, and kind before resuming:

{ok, LedgerHandle} = adk_invocation_ledger_mnesia:init(#{}),
DurableOptions = #{
ledger => {adk_invocation_ledger_mnesia, LedgerHandle},
lease_ms => 30000,
timeout => 120000
},
{ok, DurableInvocationId, DurableApprovalRef} =
erlang_adk:start_workflow_invocation(Approval, #{}, DurableOptions),
{paused, _DurablePause, _DurableCheckpoint} =
erlang_adk:await_workflow(DurableApprovalRef),
{ok, #{phase := paused, owned := false}} =
erlang_adk:workflow_invocation_status(
DurableInvocationId, DurableOptions),
%% After reconstructing Approval in a restarted application:
{ok, DurableResumedRef} = erlang_adk:resume_workflow_invocation(
DurableInvocationId, Approval,
DurableOptions#{resume_input => #{<<"approved">> => true}}),
{completed, #{<<"approval_requested">> := true,
<<"accepted">> := true}, _} =
erlang_adk:await_workflow(DurableResumedRef),
ok = erlang_adk:delete_workflow_invocation(
DurableInvocationId, DurableOptions).

The ledger checkpoints before the engine receives permission to start the next action and renews a fenced ownership lease while work is active. Renew, checkpoint and finish require the matching token and Now < lease_until in one Mnesia transaction; equality is expired. Expiry permits one atomic takeover even if an old local PID is still alive, after which its token is stale. Recovery is at-least-once only for an action whose external side effect happened before its result was durably committed; use the stable invocation/step context as an idempotency key. See durable workflow invocations for restart, replication, retention, and encryption-at-rest guidance.

branch and dynamic nodes can only select IDs listed in their targets; loop nodes require body, done, and max_iterations. Typed agent, tool, and nested workflow nodes are compiled from application-owned descriptors. No route may introduce an MFA, source string, or undeclared node at runtime. Completed fork branches and completed graph nodes are checkpointed individually; only an in-flight action that has not reached a commit boundary has at-least-once replay semantics. In particular, if one graph-fork branch pauses, an in-flight sibling is cancelled and, because it has no committed result, runs again after resume. Concurrent side-effecting branches must use the invocation/step context as an external idempotency key; a fork checkpoint does not make arbitrary external effects transactional.

A transfer is an ownership handoff, unlike a sub-agent tool call which returns to its caller. Every accepted handoff emits an adk_event action named <<"transfer_to_agent">> and consumes the transfer budget. Agent-backed actions can use {agent, RegisteredName, Prompt} or {agent, RegisteredName, Prompt, DecisionFun}; the binary name is resolved at dispatch time so a supervised replacement is used after an agent restart. Before invocation, the resolved process must report the same canonical runtime name through adk_agent:get_runtime/1; a registry alias mismatch fails closed as agent_identity_mismatch.

Checkpoints are committed only at deterministic boundaries and retain the remaining budgets. Cancellation kills active workflow workers; resuming does not replay already committed steps or replenish consumed attempts:

Parent = self(),
WaitForRelease = fun(_State) ->
Parent ! {workflow_waiting, self()},
receive
continue -> {ok, #{<<"released">> => true}}
end
end,
ResumeSpec = #{
version => 1,
id => <<"readme-resume-v1">>,
kind => sequential,
max_steps => 2,
steps => [#{id => <<"wait">>, run => WaitForRelease}]
},
{ok, ResumeCompiled} = erlang_adk:compile_workflow(ResumeSpec),
{ok, WorkflowRef} = erlang_adk:start_workflow(ResumeCompiled, #{}),
FirstWorker = receive {workflow_waiting, Pid1} -> Pid1 end,
FirstWorkerMonitor = erlang:monitor(process, FirstWorker),
{ok, #{state := running}} = erlang_adk:workflow_status(WorkflowRef),
{ok, Checkpoint} = erlang_adk:workflow_checkpoint(WorkflowRef),
ok = erlang_adk:cancel_workflow(WorkflowRef, revise_later),
{cancelled, revise_later, Checkpoint} =
erlang_adk:await_workflow(WorkflowRef),
receive
{'DOWN', FirstWorkerMonitor, process, FirstWorker, _} -> ok
end,
{ok, ResumedRef} =
erlang_adk:resume_workflow(ResumeCompiled, Checkpoint),
SecondWorker = receive {workflow_waiting, Pid2} -> Pid2 end,
SecondWorker ! continue,
{completed, #{<<"released">> := true}, _} =
erlang_adk:await_workflow(ResumedRef).

The action-result contract is {ok, Delta}, normal continuation {output, Output, Delta}, explicit terminal {stop, Output, Delta}, {route, TargetNode, Delta}, {transfer, TargetMember, NextInput, Delta}, or {error, Reason}. Legacy {complete, Output, Delta} remains terminal for compatibility. Graph actions additionally accept {pause, Reason, Summary, Delta}. A normal output is checkpointed and becomes the next sequential step, graph successor, or join node's Context.input; parallel/fork outputs are maps keyed by declared branch ID. Workflow-level input_schema and output_schema are compiled once. Individual action maps accept timeout and retry => #{max_attempts, backoff_ms}; each attempt runs in a monitored lightweight process under the workflow's absolute deadline. Retry counts cover one live execution of an uncommitted node and reset if that node is later reconstructed from a checkpoint, so external effects still need the documented idempotency key. Runtime options include timeout or an absolute monotonic deadline, max_steps, max_transfers, max_concurrency, and terminal retention_ms.

Explicit planning and replanning

Model-native thinking and executable plans are deliberately separate. Gemini thinking configuration controls how that provider produces an answer; it does not expose private reasoning as an executable plan. For an application-owned plan, use a trusted adk_planner and adk_plan_executor. The executor maps a small declared JSON action vocabulary to application operations, while the runtime supplies monitored lightweight processes, one absolute deadline, step/replan/heap/byte budgets, cancellation, and secret-pruned results.

The repository examples are deterministic and executable from ./rebar3 shell:

{ok, readme_planner} = c("examples/readme_planner.erl"),
{ok, readme_plan_executor} = c("examples/readme_plan_executor.erl"),
Planner = #{module => readme_planner,
target => undefined,
config => #{}},
Executor = #{module => readme_plan_executor,
target => undefined,
config => #{}},
Goal = #{<<"task">> => <<"prepare release">>},
Context = #{<<"invocation_id">> => <<"planning-readme">>},
{ok, PlanningResult} = erlang_adk:run_planning(
Planner, Executor, Goal, Context,
#{max_steps => 4,
max_replans => 1,
timeout_ms => 5000}),
<<"completed">> = maps:get(<<"status">>, PlanningResult),
1 = maps:get(<<"steps_executed">>, PlanningResult),
#{<<"goal">> := Goal,
<<"invocation_id">> := <<"planning-readme">>} =
maps:get(<<"result">>, PlanningResult).

For caller-controlled cancellation, use start_planning/5, cancel_planning/2, and await_planning/2. The returned opaque reference is owned by the starting process: another process cannot await or cancel it, and owner death stops the runtime and its active callback. Planner and executor module atoms always come from trusted application configuration; values in a plan can never select a module or evaluate Erlang/source/shell code. See the planning runtime contract for the canonical plan schema, adapter behaviours, failure values, and all limits.

Legacy graph helpers

Graphs are deterministic Erlang workflows. Compilation validates the entry point and deterministic edges; execution has a cycle bound:

Graph0 = adk_graph:new(),
Increment = fun(State) ->
#{<<"count">> => maps:get(<<"count">>, State, 0) + 1}
end,
Graph1 = adk_graph:add_node(Graph0, counter, Increment),
Next = fun(State) ->
case maps:get(<<"count">>, State) < 3 of
true -> counter;
false -> end_node
end
end,
Graph2 = adk_graph:add_conditional_edge(Graph1, counter, Next),
Graph3 = adk_graph:set_entry_point(Graph2, counter),
{ok, CompiledGraph} = adk_graph:compile(Graph3),
{ok, FinalState} = adk_graph:run(
CompiledGraph, #{<<"count">> => 0}, #{max_steps => 10}),
3 = maps:get(<<"count">>, FinalState).

adk_graph_node:agent_node/3, function_node/1, and tool_node/1 are helpers for event-based graphs. Agent nodes include configured instructions and model callbacks.

Event Runner

The Runner stores user, model, and tool events in one session and clears temp: state only at a final or error boundary:

RunnerConfig = #{provider => adk_llm_gemini,
instructions => <<"Be concise.">>},
{ok, RunnerAgentPid} = erlang_adk:spawn_agent(
<<"RunnerAgent">>, RunnerConfig, []),
Runner = adk_runner:new(
RunnerAgentPid, <<"readme_app">>, erlang_adk_session,
#{run_timeout => 120000,
max_llm_calls => 32,
max_tool_rounds => 16,
streaming_mode => text,
max_stream_output_bytes => 16777216,
admission_control => #{overflow => queue,
queue_timeout => 5000},
runtime_policy =>
#{id => <<"readme-runner-policy">>,
agents => #{allow => [<<"RunnerAgent">>]},
tools => #{allow => []},
max_argument_bytes => 32768,
max_content_bytes => 262144},
context_policy => #{max_bytes => 32768,
max_tokens => 8192,
max_request_bytes => 65536,
max_request_tokens => 16384,
overflow => truncate}}),
{ok, RunnerResponse} = adk_runner:run(
Runner, <<"user-1">>, <<"session-1">>, <<"Hello">>),
io:format("~ts~n", [RunnerResponse]).

streaming_mode => text makes the Runner use the provider's UTF-8 delta stream. content selects canonical adk_content deltas, and the default none uses one-shot generation. Provider I/O executes in the independently supervised invocation worker rather than the agent gen_server mailbox, so the agent remains responsive to runtime inspection and cancellation routing. The short preparation/finalization calls are still serialized by that agent.

max_stream_output_bytes bounds one model round's accumulated callback payload (16 MiB by default, with a 64 MiB hard ceiling). Canonical content is also subject to its part, text, inline-data, URI, and function-payload limits. An unsupported provider content stream, invalid UTF-8/content delta, empty stream, or exceeded bound fails the invocation explicitly.

run_timeout is the overall synchronous Runner deadline. A non-streaming model round invoked through adk_agent:run_with_events/3 has a separate 60-second gen_server call limit by default, so a larger Runner deadline can cover several model/tool rounds but does not itself extend one non-streaming model round. Applications that need a different agent call limit can set the erlang_adk application environment key agent_call_timeout to a non-negative number of milliseconds (or infinity); adk_agent:prompt/2 and run_with_events/3,4 use it. Blocking direct-turn workers have the separate agent_turn_timeout setting, also 60 seconds by default, so work is cancelled and cleaned up even when a caller process survives its gen_server call timeout. Set both values when intentionally allowing longer turns; explicit infinity is an opt-in for either boundary.

Use one active Runner invocation per session. Independent agents and sessions can run concurrently, but sharing one session between simultaneous invocations can interleave event history and invocation-temp state.

max_llm_calls and max_tool_rounds are independent invocation budgets. They default to 32 and 16 respectively and prevent an otherwise valid model/tool conversation from looping forever.

Runner always canonicalizes and secret-prunes model history. context_policy adds filtering and selected-event budgets before each model call. overflow => truncate keeps the newest complete context exchanges; it never separates a tool call from its responses. error fails explicitly, while compress requires an owner-bound monitored compressor with timeout, heap, event-count, and output-size bounds. Author, invocation, content type, time-range, partial, and final-event filters are supported. The current invocation's user event may not be filtered out.

max_request_bytes and max_request_tokens are a second fail-closed boundary over the complete sanitized provider envelope: effective instructions and generation settings, selected history, retrieved memory, current input, multimodal parts, tool declarations, and framing. A violation returns {request_context_budget_exceeded, Details} before provider I/O. Context-build telemetry is emitted as [erlang_adk, context, build]; complete-envelope telemetry uses [erlang_adk, context, envelope]. Both expose bounded counts, estimates, and deterministic context fingerprints without prompt content or credentials. Exact provider tokenization remains a provider adapter concern. See the context guide for compaction, provider-prefix cache, and least-authority tool context contracts.

Admission control and runtime policy

The application always supervises one adk_admission_control process. A Runner opts into it with admission_control => #{...}. reject returns a busy error immediately; queue uses a bounded queue and an absolute monotonic deadline derived from queue_timeout. Limits are global and per agent. The invocation worker owns its permit, so normal completion releases it explicitly and an untrappable cancellation, timeout, or crash releases it through the controller's process monitor.

Configure node-wide limits before application startup (for example in sys.config):

[{erlang_adk,
[{admission_control,
#{global_limit => 256,
default_agent_limit => 16,
agent_limits => #{<<"BatchAgent">> => 4},
overflow => reject,
max_queue => 1024,
default_queue_timeout => 30000}}]}].

runtime_policy is optional, but once configured it is fail-closed. An omitted agent/tool allow selector means no names are allowed; use allow => all explicitly when intended, and deny always wins. Binary content is measured as UTF-8 payload bytes, while structured content and final resolved tool arguments are measured as canonical JSON. Finite defaults are 64 KiB for arguments and 1 MiB for content.

Tool policy runs after Erlang module/OpenAPI/MCP/dynamic-toolset resolution but before global/local tool callbacks and human approval. A denied tool therefore cannot execute, invoke policy-bypass callbacks, or become an approval request. It becomes a bounded structural tool error so the model can continue. Input, tool-result, and final-output budget failures never persist the rejected value.

Every denial is persisted and streamed as a canonical system event whose actions contains <<"runtime_policy_decision">>. The immutable decision has IDs, policy fingerprint, operation, subject, outcome/reason, and byte counts; it never contains arguments, content, credentials, exception terms, pids, or references. Audit events deliberately bypass mutable on_event plugins. See Runtime safety and admission control for the direct API, telemetry fields, release semantics, and Phoenix ownership guidance.

For application-facing asynchronous work, prefer the supervised run API. The run is not owned by the process that starts it or by a browser subscriber, so a caller disconnect does not terminate useful work:

Each dynamic invocation child specification contains only a fresh opaque reference. The run ID, Runner handle, request/prompt, and options cross a validated one-shot handoff after the empty child starts; a rejected handoff or duplicate-registration loser is cleaned up synchronously. OTP diagnostic status exposes only bounded lifecycle counters, while the explicit adk_run:status/1 API continues to return the caller's run ID and outcome.

{ok, RunId} = adk_run:start(
Runner, <<"user-1">>, <<"session-supervised">>,
<<"Explain supervisors.">>,
#{retention_ms => 60000, max_buffered_events => 256}),
ok = adk_run:subscribe(RunId),
ReceiveRun = fun Loop(Id) ->
receive
{adk_run_event, Id, Sequence, Event} ->
io:format("~p ~p~n", [Sequence, adk_event:to_map(Event)]),
Loop(Id);
{adk_run_terminal, Id, _Sequence, Outcome} ->
Outcome
after 120000 ->
{error, subscriber_timeout}
end
end,
{completed, SupervisedText} = ReceiveRun(RunId),
io:format("~ts~n", [SupervisedText]),
{ok, #{state := completed}} = adk_run:status(RunId).

Late subscribers receive the bounded replay followed by the same terminal outcome while the run is retained. adk_run:await/1,2 waits without subscribing; adk_run:cancel/1,2 propagates cancellation to the Runner worker. For a slow network or broker boundary, use adk_run:subscribe_credit/2,3 with the last consumed sequence and return credit through adk_run:ack/2,3; this permits at most one unacknowledged run message per subscriber and reports a replay_gap instead of silently skipping events. The original subscribe/1,2 push API is preserved for local Erlang processes that continuously drain their mailbox.

With provider streaming, each delta is persisted as #adk_event{partial = true}. One is_final = true event contains the complete response, and the completed run outcome contains that same response exactly once. A UI should append/render partials provisionally, then replace that provisional view with the final snapshot; it must not append the final snapshot to the partial text. Partial events remain replayable but are excluded from the next model turn's history. Exactly one terminal outcome is committed: {completed, Output} (a UTF-8 binary or canonical adk_content), {paused, Event}, {cancelled, Reason}, or {failed, Reason}.

Resume a supervised paused run through adk_run, not by turning the approval into a new user prompt:

{paused, _PauseEvent} = adk_run:await(PausedRunId, 120000),
{ok, ResumedRunId} = adk_run:resume(
PausedRunId,
#{<<"approved">> => true, <<"approver">> => <<"operator@example.com">>},
#{retention_ms => 60000, max_buffered_events => 256}),
{completed, ResumedText} = adk_run:await(ResumedRunId, 120000).

The original paused run stays immutable. Its status exposes resumed_to; the new run exposes parent_run_id. A second resume returns {error, {already_resumed, ResumedRunId}}.

Ambient/background invocation

Register an application-owned Runner once, then synchronously submit each delivery into a bounded queue. Submission returns immediately with a stable event reference; the invocation and its retry lifecycle are owned by supervised lightweight processes rather than by the HTTP/queue consumer:

ok = adk_ambient:register_trigger(
<<"inbox-events">>, Runner,
#{max_concurrency => 8,
max_queue => 256,
event_timeout => 120000,
retention_ms => 300000,
max_retained => 1000,
max_event_bytes => 1048576,
session_policy => #{mode => per_event,
user_id => <<"background-worker">>,
prefix => <<"inbox-">>},
retry => #{max_attempts => 3,
initial_delay => 250,
max_delay => 5000,
backoff_factor => 2.0,
attempt_timeout => 30000,
max_heap_words => 1000000,
jitter => full}}),
Delivery = #{payload => <<"Summarize message 42">>,
idempotency_key => <<"mailbox:42:v1">>,
metadata => #{source => <<"local-queue">>}},
{ok, AmbientRef} = adk_ambient:submit(<<"inbox-events">>, Delivery),
{completed, #{run_id := _AmbientRunId,
output := AmbientOutput}} =
adk_ambient:await(AmbientRef, 120000),
io:format("~ts~n", [AmbientOutput]),
{ok, #{state := terminal, attempts := _AttemptCount}} =
adk_ambient:status(AmbientRef).

Every event must carry a non-empty idempotency_key. A duplicate returns {ok, ExistingRef, duplicate} and cannot start a second invocation while the original result is retained. Dedupe and trigger registration are node-local and bounded by retention_ms, max_retained, and the application-wide ambient_runtime.max_events; a durable broker should redeliver until its adapter has successfully called submit/2. Retried invocations reuse the same session, so tools called by a retry must also be idempotent.

Session ownership cannot be implicit. per_event derives a separate stable session from the idempotency key; explicit requires session => #{user_id => ..., session_id => ...} on every event; shared uses one configured session and should only be selected when interleaved history is intended. Per-event timeout_ms may shorten, but never extend, the registered event deadline. Queue wait, retry backoff, and run execution consume that one absolute deadline. cancel/2 covers both queued and active events and publishes a terminal result only after the run and admission permit are cleaned up.

The bundled schedule source owns one fixed-delay timer and also submits through the same bounded path:

{ok, SchedulePid} = adk_trigger_schedule:start(
<<"inbox-events">>, <<"hourly-inbox-summary">>, 3600000,
#{payload => <<"Summarize the last hour">>},
#{initial_delay_ms => 3600000}),
{ok, #{type := schedule, interval_ms := 3600000}} =
adk_trigger_schedule:status(SchedulePid),
ok = adk_trigger_schedule:stop(SchedulePid),
ok = adk_ambient:unregister_trigger(<<"inbox-events">>).

adk_trigger_source is the provider-neutral OTP behaviour for additional sources. Pub/Sub, Eventarc, Kafka, RabbitMQ, cron services, and cloud push verification remain deployment adapters; the core deliberately has no cloud SDK dependency. Adapters apply backpressure by calling submit/2, acknowledge only after acceptance, and use the provider delivery ID as the idempotency key. The bundled scheduler is fixed-delay, not a cron parser. See the ambient runtime contract for every option, outcome, cleanup guarantee, and adapter responsibility.

The lower-level Runner stream remains available when a caller deliberately wants to own and drain the mailbox protocol:

Drain = fun Loop(StreamPid) ->
receive
{adk_event, StreamPid, Event} ->
io:format("event: ~p~n", [adk_event:to_map(Event)]),
Loop(StreamPid);
{adk_done, StreamPid} -> ok;
{adk_paused, StreamPid, PauseEvent} -> {paused, PauseEvent};
{adk_error, StreamPid, Reason} -> {error, Reason}
after 120000 ->
{error, timeout}
end
end,
{ok, StreamPid} = adk_runner:run_async(
Runner, <<"user-1">>, <<"session-2">>, <<"Explain supervisors.">>),
ok = Drain(StreamPid),
ok = erlang_adk:stop_agent(RunnerAgentPid).

Human approval pause/resume

A side-effecting adk_tool can export require_confirmation/0 for a static gate or require_confirmation/2 for an argument-dependent gate. The latter takes precedence and returns false, true, or a bounded map such as #{required => true, hint => <<"Publish production">>}. Runner evaluates it after schema validation and runtime policy, but before tool/plugin lifecycle callbacks or execution. A dynamic toolset is materialized after policy so its validated resolved call can declare confirmation; that resolver may obtain a scoped credential but must not perform the business side effect. A confirmation is a serial barrier even when neighboring calls are parallel-safe.

The repository's release example requires confirmation for a real publication but lets dry_run => true proceed. Use the supervised run API so the approval survives the original caller:

{ok, readme_release_tool} = c("examples/readme_release_tool.erl"),
ReleaseConfig = #{
provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>,
instructions =>
<<"Call publish_release once for production with dry_run=false. "
"After its result, answer without calling it again.">>
},
{ok, ReleasePid} = erlang_adk:spawn_agent(
<<"ReleaseAgent">>, ReleaseConfig, [readme_release_tool]),
ReleaseRunner = adk_runner:new(
ReleasePid, <<"readme_app">>, erlang_adk_session),
{ok, ReleaseRun} = adk_run:start(
ReleaseRunner, <<"user-1">>, <<"release-session">>,
<<"Publish the prepared production release.">>),
case adk_run:await(ReleaseRun, 120000) of
{paused, ConfirmationPause} ->
ConfirmationMap = adk_event:to_map(ConfirmationPause),
#{<<"pause">> :=
#{<<"details">> :=
#{<<"type">> := <<"tool_confirmation">>,
<<"action_id">> := _OpaqueActionId}}} =
maps:get(<<"actions">>, ConfirmationMap),
{ok, ApprovedReleaseRun} = adk_run:resume(
ReleaseRun, #{<<"confirmed">> => true}),
case adk_run:await(ApprovedReleaseRun, 120000) of
{completed, ReleaseAnswer} ->
io:format("~ts~n", [ReleaseAnswer]);
{paused, NextPause} ->
io:format("tool requested another pause: ~p~n",
[adk_event:to_map(NextPause)])
end;
{completed, ReleaseAnswer} ->
io:format("model completed without using the tool: ~ts~n",
[ReleaseAnswer])
end,
ok = erlang_adk:stop_agent(ReleasePid),
%% Long-running work is a separate pause type. This compatibility example
%% lets the model request an explicit human response itself.
ApprovalConfig = #{
provider => adk_llm_gemini,
instructions =>
<<"Before any destructive action, call request_human_approval.">>
},
{ok, ApprovalPid} = erlang_adk:spawn_agent(
<<"ApprovalAgent">>, ApprovalConfig, [adk_long_running_tool]),
ApprovalRunner = adk_runner:new(
ApprovalPid, <<"readme_app">>, erlang_adk_session),
ApprovalDrain = fun Loop(ApprovalStreamPid) ->
receive
{adk_event, ApprovalStreamPid, ApprovalEvent} ->
io:format("event: ~p~n", [adk_event:to_map(ApprovalEvent)]),
Loop(ApprovalStreamPid);
{adk_done, ApprovalStreamPid} -> ok;
{adk_paused, ApprovalStreamPid, NextPause} -> {paused, NextPause};
{adk_error, ApprovalStreamPid, ResumeReason} -> {error, ResumeReason}
after 120000 ->
{error, timeout}
end
end,
ApprovalResult = adk_runner:run(
ApprovalRunner, <<"user-1">>, <<"approval-session">>,
<<"Delete the obsolete deployment.">>),
case ApprovalResult of
{paused, PauseEvent} ->
io:format("approval requested: ~p~n", [adk_event:to_map(PauseEvent)]),
PauseMap = adk_event:to_map(PauseEvent),
#{<<"pause">> :=
#{<<"continuation_id">> := ContinuationId}} =
maps:get(<<"actions">>, PauseMap),
{ok, ResumePid} = adk_runner:resume(
ApprovalRunner, <<"user-1">>, <<"approval-session">>,
ContinuationId,
#{approved => true, approver => <<"operator@example.com">>}),
ok = ApprovalDrain(ResumePid);
{ok, UnexpectedFinal} ->
io:format("model completed without pausing: ~ts~n", [UnexpectedFinal]);
{error, ApprovalReason} ->
io:format("approval workflow failed: ~p~n", [ApprovalReason])
end,
ok = erlang_adk:stop_agent(ApprovalPid).

Resume a generic confirmation with exactly #{<<"confirmed">> => true | false}. Approval re-resolves the tool and rechecks current policy before executing exactly once. Rejection executes no tool or tool callback; it records a correlated model-visible rejection and continues the invocation. An invalid response does not consume the continuation, while a valid continuation is single-use. The opaque action ID is a digest and contains no raw arguments. Non-Runner agent execution (prompt/2, fresh invoke/3, delegation, and AgentTool-backed child calls) and typed workflow tool actions have no durable approval channel, so a required confirmation fails closed as tool_confirmation_requires_runner; they never silently execute. Structured “modify arguments” confirmation responses are not supported.

adk_long_running_tool uses the distinct long-running suspension contract. A pause is not an error; resume records a matching function response with the original invocation ID, thought signature, and Gemini function-call ID.

Use resume/5 with the public continuation/invocation ID whenever concurrent invocations can share a session. resume/4 is a compatibility convenience and returns {error, {ambiguous_paused_invocation, InvocationIds}} if more than one pause exists. A continuation is consumed atomically; replay returns {error, no_paused_invocation}. Temp state is retained while paused and removed after the resumed invocation completes or fails. Erlang maps returned by tools are normalized to JSON-safe binary keys, so the atom-keyed approval map above round-trips through event JSON safely.

Generic long-running operations

A pause-capable tool can suspend without keeping a worker process blocked:

adk_suspension:long_running(
<<"export-42">>, <<"The external export is running.">>).

The application records correlated non-terminal updates while the continuation remains resumable, then supplies one terminal response:

PauseMap = adk_event:to_map(PauseEvent),
InvocationId = maps:get(<<"invocation_id">>, PauseMap),
{ok, _ProgressEvent} = adk_runner:update_long_running(
Runner, <<"user-1">>, <<"export-session">>, InvocationId,
<<"export-42">>,
#{<<"operation_id">> => <<"export-42">>,
<<"status">> => <<"running">>,
<<"progress">> => 50}),
{ok, _ResumeStream} = adk_runner:resume(
Runner, <<"user-1">>, <<"export-session">>, InvocationId,
#{<<"operation_id">> => <<"export-42">>,
<<"status">> => <<"completed">>,
<<"result">> => #{<<"artifact_id">> => <<"export.zip">>}}).

Operation ID and terminal status are checked before the continuation is claimed. ETS and Mnesia sessions atomically order progress against terminal resume, so a racing update cannot attach to a consumed or different operation. Custom session backends must implement add_event_if_state/6 to support progress updates.

Session state

State prefixes have these lifetimes:

Create a session before updating it:

ok = erlang_adk_session:init(),
AppName = <<"state_demo">>,
UserId = <<"user-1">>,
SessionId = <<"state-session-1">>,
{ok, _CreatedSession} = erlang_adk_session:create_session(
AppName, UserId, #{session_id => SessionId}),
ok = erlang_adk_session:update_state(
AppName, UserId, SessionId,
#{<<"theme">> => <<"local">>,
<<"user:preferences">> => <<"dark">>,
<<"app:release">> => <<"0.3.0">>,
<<"temp:lookup">> => <<"in-flight">>}),
{ok, StoredSession} = erlang_adk_session:get_session(
AppName, UserId, SessionId),
StoredState = maps:get(state, StoredSession),
<<"dark">> = maps:get(<<"user:preferences">>, StoredState),
{ok, FutureSession} = erlang_adk_session:create_session(
AppName, UserId, #{session_id => <<"state-session-2">>}),
FutureState = maps:get(state, FutureSession),
<<"dark">> = maps:get(<<"user:preferences">>, FutureState),
<<"0.3.0">> = maps:get(<<"app:release">>, FutureState),
error = maps:find(<<"theme">>, FutureState),
ok = erlang_adk_session:clear_temp_state(AppName, UserId, SessionId),
ok = erlang_adk_session:delete_session(AppName, UserId, SessionId),
ok = erlang_adk_session:delete_session(
AppName, UserId, <<"state-session-2">>).

update_state/4 and add_event/4 return {error, not_found} for a missing session. Creation is idempotent and does not overwrite an existing session during concurrent first use.

For a local disk-backed Mnesia session backend, initialize Mnesia and pass it to the Runner:

ok = erlang_adk_session_mnesia:init(),
{ok, PersistentAgentPid} = erlang_adk:spawn_agent(
<<"PersistentAgent">>, #{provider => adk_llm_gemini}, []),
MnesiaRunner = adk_runner:new(
PersistentAgentPid, <<"persistent_app">>, erlang_adk_session_mnesia),
{ok, PersistentResponse} = adk_runner:run(
MnesiaRunner, <<"user-1">>, <<"persistent-session">>, <<"Hello">>),
io:format("~ts~n", [PersistentResponse]),
ok = erlang_adk:stop_agent(PersistentAgentPid).

init/0 creates disc_copies on the current node only. A distributed deployment must separately configure the Mnesia cluster and add table replicas on the other nodes.

Mnesia is no longer started by the default application path. To make it the configured backend and fail application startup if it cannot initialize, set the backend before starting erlang_adk:

_ = application:stop(erlang_adk),
ok = application:set_env(
erlang_adk, session_backend, erlang_adk_session_mnesia),
{ok, _} = application:ensure_all_started(erlang_adk).

The older agent session_id option persists that agent's conversation history through the backend's legacy save/2 and load/1 API. Runner sessions are the recommended event/state API.

For authenticated, snapshot-bound pagination and non-destructive rewind, use adk_session_query. Cursor secrets belong in application secret storage and must be stable across the nodes that serve a cursor:

QuerySecret = adk_session_query:new_cursor_secret(),
QueryApp = <<"query_app">>,
QueryUser = <<"user-1">>,
SourceSession = <<"query-a">>,
BranchSession = <<"query-a-before-answer">>,
{ok, _} = erlang_adk_session:create_session(
QueryApp, QueryUser, #{session_id => SourceSession}),
ok = erlang_adk_session:add_event(
QueryApp, QueryUser, SourceSession,
adk_event:with_state_delta(
adk_event:new(<<"user">>, <<"question">>),
#{<<"step">> => 1})),
ok = erlang_adk_session:add_event(
QueryApp, QueryUser, SourceSession,
adk_event:with_state_delta(
adk_event:new(<<"agent">>, <<"answer">>, #{is_final => true}),
#{<<"step">> => 2})),
{ok, SessionPage} = adk_session_query:list(
erlang_adk_session, QueryApp, QueryUser,
#{limit => 10, cursor_secret => QuerySecret}),
[_ | _] = maps:get(sessions, SessionPage),
{ok, EventPage} = adk_session_query:get(
erlang_adk_session, QueryApp, QueryUser, SourceSession,
#{event_limit => 1, cursor_secret => QuerySecret}),
#{next_cursor := EventCursor} = maps:get(event_page, EventPage),
true = is_binary(EventCursor),
{ok, #{session_id := BranchSession, events_copied := 1}} =
adk_session_query:rewind(
erlang_adk_session, QueryApp, QueryUser, SourceSession,
{index, 1}, #{target_session_id => BranchSession}),
{ok, SourceAfterRewind} = erlang_adk_session:get_session(
QueryApp, QueryUser, SourceSession),
2 = length(maps:get(events, SourceAfterRewind)),
ok = erlang_adk_session:delete_session(
QueryApp, QueryUser, SourceSession),
ok = erlang_adk_session:delete_session(
QueryApp, QueryUser, BranchSession).

List and event cursors are HMAC-authenticated and bound to the complete snapshot, scope, filter, order, and page size. A changed source returns stale_cursor; a forged or cross-scope cursor returns invalid_cursor. Rewind always creates a new session and never edits its source. Replay of temp: deltas is blocked, while app:/user: deltas require an explicit shared-state opt-in.

Callbacks and telemetry

The repository callback example uses the actual provider-result contract:

-module(readme_audit_callback).
-behaviour(adk_callbacks).
-export([set_observer/1, clear_observer/0,
before_model/3, after_model/2, before_tool/3, after_tool/4]).
set_observer(Pid) when is_pid(Pid) ->
persistent_term:put({?MODULE, observer}, Pid),
ok.
clear_observer() ->
persistent_term:erase({?MODULE, observer}),
ok.
before_model(_Config, _Memory, Tools) ->
notify({before_model, length(Tools)}),
ok.
after_model(_Config, ProviderResult) ->
notify({after_model, ProviderResult}),
ok.
before_tool(_ToolName, _Args, _Context) -> ok.
after_tool(_ToolName, _Args, _Context, _ToolResult) -> ok.
notify(Message) ->
case persistent_term:get({?MODULE, observer}, undefined) of
Pid when is_pid(Pid) -> Pid ! Message;
_ -> ok
end.

Attach it to an agent:

{ok, readme_audit_callback} = c("examples/readme_audit_callback.erl"),
ok = readme_audit_callback:set_observer(self()),
CallbackConfig = #{provider => adk_llm_gemini,
callbacks => [readme_audit_callback]},
{ok, CallbackPid} = erlang_adk:spawn_agent(
<<"CallbackAgent">>, CallbackConfig, []),
{ok, _CallbackResponse} = erlang_adk:prompt(
CallbackPid, <<"Say hello.">>),
receive {before_model, ToolCount} -> io:format("~p tools~n", [ToolCount]) end,
receive {after_model, ProviderResult} -> io:format("~p~n", [ProviderResult]) end,
ok = readme_audit_callback:clear_observer(),
ok = erlang_adk:stop_agent(CallbackPid).

Before-hooks return ok/continue, {halt, ProviderResult} to skip the operation, or {replace, Value}. After-hooks can return {replace, Value}. Callback modules are loaded on demand; callback exceptions do not crash the agent. Failures delivered to error hooks, public outcomes, model-visible tool responses, and logs contain bounded structural tags only—never a raw exception reason, stacktrace, provider response body, or agent configuration.

Prompt, delegation, and event-model calls emit telemetry. This example observes completed prompts:

TelemetryHandler = fun(_Event, Measurements, Metadata, _Config) ->
io:format("agent=~p duration_ms=~p~n",
[maps:get(agent, Metadata), maps:get(duration, Measurements)])
end,
_ = telemetry:detach(<<"readme-prompt-handler">>),
ok = telemetry:attach(
<<"readme-prompt-handler">>,
[erlang_adk, agent, prompt, stop], TelemetryHandler, #{}),
{ok, TelemetryPid} = erlang_adk:spawn_agent(
<<"TelemetryAgent">>, #{provider => adk_llm_gemini}, []),
{ok, _TelemetryResponse} = erlang_adk:prompt(
TelemetryPid, <<"Say hello.">>),
ok = telemetry:detach(<<"readme-prompt-handler">>),
ok = erlang_adk:stop_agent(TelemetryPid).

Multimodal content

adk_content is the provider-neutral, versioned, JSON-safe content boundary. It supports text, base64-encoded inline bytes, https:///gs:// file references, function calls, and function responses. Constructors validate before returning; raw inline bytes are converted to canonical base64 so a content value can safely cross process, event, session, and JSON boundaries:

TinyPng = base64:decode(
<<"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=">>),
{ok, PromptPart} = adk_content:text(<<"Describe this image briefly.">>),
{ok, ImagePart} = adk_content:inline_data(<<"image/png">>, TinyPng),
{ok, MultimodalPrompt} = adk_content:new([PromptPart, ImagePart]),
{ok, MultimodalResult} = adk_llm:generate(
#{provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>},
[#{role => user, content => MultimodalPrompt}], []),
case MultimodalResult of
Text when is_binary(Text) -> io:format("~ts~n", [Text]);
Content when is_map(Content) ->
{ok, Content} = adk_content:validate(Content),
io:format("multimodal response: ~p~n", [Content])
end.

Text-only responses retain the existing {ok, Utf8Binary} API. If Gemini returns inline or referenced media, the successful value is the canonical adk_content map instead of lossy JSON text. The same content value can be used as a direct-agent or Runner input; final multimodal output is preserved as structured event content rather than coerced to a printable Erlang term.

Validation defaults are intentionally below transport maxima: 64 parts, 1 MiB per text part, 10 MiB per inline part, 15 MiB of inline data in total, 4 KiB per URI, and 1 MiB per function payload. An application may lower limits with content_limits; ceilings prevent configuration from disabling the safety boundary. MIME types must be lowercase type/subtype values without parameters. File parts reject relative URIs, credentials, fragments, local file://, http://, and data: URIs. Fetching a permitted remote URI is a Gemini service operation—the Erlang adapter does not fetch it locally.

The existing adk_llm:stream/4 remains a UTF-8 text-delta API and fails with {error, {unsupported_text_stream_part, Type}} rather than silently dropping media. Use the Gemini adapter's content stream when every decoded SSE frame must remain structured:

ContentCallback = fun(ContentDelta) ->
{ok, ContentDelta} = adk_content:validate(ContentDelta),
io:format("content delta: ~p~n", [ContentDelta])
end,
ok = adk_llm_gemini:stream_content(
#{model => <<"gemini-3.1-flash-lite">>},
[#{role => user, content => MultimodalPrompt}], [], ContentCallback).

stream_content/4 invokes the callback once per decoded provider frame; it does not append a second buffered final content value. Gemini Live's bidirectional WebSocket lifecycle (audio/video input, interruption, session resumption, and backpressure) is a separate protocol; use the Live runtime below rather than treating REST SSE as a bidirectional session.

Gemini Live

Gemini Live sessions run as independent, server-owned gen_statem processes under adk_live_session_sup. The process that starts a session does not own its lifetime. Every operation must present the exact opaque Principal used at startup; only its SHA-256 digest enters long-lived session state.

Live deliberately uses the explicit preview model gemini-3.1-flash-live-preview. The REST default remains gemini-3.1-flash-lite; a REST model is rejected by the Live provider instead of silently falling back. The Gemini 3.1 Live model produces native AUDIO, so the setup requests [audio] and output_audio_transcription => true supplies displayable text alongside the audio stream.

The checked Live config also supports bounded system instructions, voice selection, input transcription, thinking configuration, media resolution, context-window compression, session resumption, function declarations, and the provider google_search declaration. Usage, grounding, GoAway, and resumption updates arrive as structural events. Proactive/affective audio, structured output, REST context caching, and arbitrary older Live options are rejected rather than silently ignored.

From ./rebar3 shell, start a future-only subscription before waiting for the ready event:

ApiKeyString = os:getenv("GEMINI_API_KEY"),
true = is_list(ApiKeyString),
ApiKey = unicode:characters_to_binary(ApiKeyString),
LivePrincipal = <<"readme-live-user">>,
LiveSessionId = <<"readme-live-session">>,
LiveConfig =
#{provider => adk_live_gemini,
provider_config =>
#{model => <<"gemini-3.1-flash-live-preview">>,
response_modalities => [audio],
output_audio_transcription => true,
automatic_activity_detection => true,
session_resumption => true},
transport => adk_live_gun_transport,
transport_opts => #{api_key => ApiKey},
max_ingress_messages => 64,
max_ingress_bytes => 4194304,
max_subscribers => 64,
max_subscriber_messages => 128,
max_subscriber_bytes => 8388608},
{ok, LiveSession} = erlang_adk:start_live_session(
LiveSessionId, LivePrincipal, LiveConfig),
{ok, _LiveSubscription} = erlang_adk:live_subscribe(
LiveSession, LivePrincipal, #{messages => 16, bytes => 4194304}),
ReceiveLive =
fun Loop(Matches) ->
receive
{adk_live_event, LiveSessionId, Sequence, Event} ->
ok = erlang_adk:live_ack(
LiveSession, LivePrincipal, Sequence),
case Matches(Event) of
true -> {ok, Event};
false -> Loop(Matches)
end;
{adk_live_subscriber_dropped, LiveSessionId, DropReason} ->
{error, {subscriber_dropped, DropReason}}
after 30000 ->
{error, live_event_timeout}
end
end,
{ok, _ReadyEvent} = ReceiveLive(
fun(Event) -> adk_live_event:kind(Event) =:= ready end),
{ok, _InputSequence} = erlang_adk:live_send_text(
LiveSession, LivePrincipal, <<"Explain OTP in one sentence.">>),
CollectTranscript =
fun Loop(Acc) ->
receive
{adk_live_event, LiveSessionId, Sequence, Event} ->
ok = erlang_adk:live_ack(
LiveSession, LivePrincipal, Sequence),
case adk_live_event:kind(Event) of
output_transcription ->
#{text := Chunk} = maps:get(payload, Event),
Loop(<<Acc/binary, Chunk/binary>>);
turn_complete ->
{ok, Acc};
error ->
{error, {live_error, maps:get(payload, Event)}};
terminal ->
{error, {live_terminal, maps:get(payload, Event)}};
_ ->
Loop(Acc)
end;
{adk_live_subscriber_dropped, LiveSessionId, DropReason} ->
{error, {subscriber_dropped, DropReason}}
after 120000 ->
{error, live_turn_timeout}
end
end,
{ok, Transcript} = CollectTranscript(<<>>),
true = byte_size(string:trim(Transcript)) > 0,
io:format("~ts~n", [Transcript]),
ok = erlang_adk:live_unsubscribe(LiveSession, LivePrincipal),
ok = erlang_adk:close_live_session(
LiveSession, LivePrincipal, normal).

max_subscribers defaults to 64 and has a hard implementation maximum of 4096. Admission beyond the configured per-session limit returns {error, subscriber_limit}. Explicit unsubscribe, slow-subscriber removal, or subscriber-process death releases the slot; each admitted subscriber still has its own independent message/byte credit window and acknowledgement state. The supervisor-wide application environment setting live_session_limit defaults to 1024 and accepts values only from 1 through 16384. Concurrent session creation is serialized around a constant-time supervisor count, so an over-cap start returns {error, live_session_limit} without first enumerating an unbounded child set. Change this setting before application startup.

The production transport is intentionally fixed to wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent; only the API-key query value is added at runtime. TLS peer and hostname verification cannot be disabled. If the host runtime cannot discover an OS CA store, point it at a trusted bundle with transport_opts => #{api_key => ApiKey, cacertfile => <<"/path/to/ca-bundle.pem">>}. The transport never falls back to verify_none.

Live PCM input is signed 16-bit little-endian mono at 16 kHz. Gemini audio events are signed 16-bit little-endian mono at 24 kHz. Keep those raw binaries off ordinary session/event persistence; Live audio events are explicitly ephemeral. JPEG and PNG video frames use the same bounded input queue. The v1beta codec emits the current realtimeInput.audio and realtimeInput.video fields, not the superseded mediaChunks shape:

{ok, AudioChunk} = adk_live_media:audio_pcm(Pcm16KhzS16le, 16000, 1),
{ok, _} = erlang_adk:live_send_audio(
LiveSession, LivePrincipal, AudioChunk),
{ok, VideoFrame} = adk_live_media:video_frame(jpeg, JpegBytes),
{ok, _} = erlang_adk:live_send_video_frame(
LiveSession, LivePrincipal, VideoFrame).

With automatic activity detection disabled, bracket audio with live_activity_start/2 and live_activity_end/2; use live_audio_stream_end/2 only when the input stream itself has ended. Interruption, generation completion, and turn completion are distinct events. An interruption purges queued output audio from that generation.

The Gemini decoder also accepts the current optional Live response fields: interimInputTranscription, transcription languageCode/finished, turnCompleteReason, waitingForInput, voiceActivity, and voiceActivityDetectionSignal. Provider completion reasons remain bounded enum-shaped binaries rather than dynamically created atoms, so a future reason can pass through safely. Language and server VAD metadata are validated but are not exposed as new provider-specific public events; text, finality, completion, and the existing activity lifecycle remain the provider-neutral contract.

Owner-bound browser voice bridge

start_live_voice_bridge/4 provides a transport-neutral Erlang boundary for a browser or native audio adapter without moving device handling into the Live session. Each bridge is one lightweight, owner-bound process. It subscribes to the existing server-owned session with fixed byte/message credit, validates strict versioned binary input, preserves synchronous ingress backpressure, and forwards only PCM audio, transcription, and lifecycle projections. Raw provider payloads, credentials, and the principal never enter its owner mailbox. The session must already report active. A monitored node-local lease admits exactly one bidirectional bridge per Live session, while bridges for different sessions remain independent lightweight processes. Reconnect rotates a session-owned continuity capability and sends a credit-independent invalidation to the bridge; even a fast resume with exhausted output credit cannot admit stale audio or retain the old lease. It returns live_voice_reconnect_required; an adapter starts a fresh bridge only after the server-owned session is active again. If an input or ACK deadline leaves its outcome ambiguous, the bridge terminates and returns live_voice_outcome_unknown so a transport cannot retry possible side effects.

{ok, VoiceBridge} = erlang_adk:start_live_voice_bridge(
LiveSession,
LivePrincipal,
self(),
#{credit => #{messages => 8, bytes => 262144},
max_audio_frame_bytes => 64000}),
%% Submit 20 ms of mono PCM s16le at 16 kHz. Client audio sequences start at 1.
Pcm20ms = binary:copy(<<0, 0>>, 320),
{ok, _InputSequence} = erlang_adk:live_voice_frame(
VoiceBridge,
<<1, 1, 1:64/unsigned-big, 16000:32/unsigned-big, 1,
Pcm20ms/binary>>),
{ok, _EndSequence} = erlang_adk:live_voice_frame(VoiceBridge, <<1, 2>>),
ok = erlang_adk:stop_live_voice_bridge(VoiceBridge).

The v1 client frame types are audio 1, stream-end 2, event ACK 3, activity-start 4, and activity-end 5. Server types are audio 129, transcription 130, and lifecycle 131; all header integers are big-endian, while PCM samples remain signed 16-bit little-endian. Every forwarded server frame retains ADK subscriber credit until the client returns its exact sequence in an ACK frame. The Phoenix reference adapter defers an audio ACK until every byte in that frame has entered its bounded playback schedule: at most two seconds are scheduled, while a FIFO mirrors the bridge's eight-event/262,144-byte credit bound. A full scheduling horizon withholds credit and drains on source completion; it never purges already scheduled PCM merely to admit newer audio. Interruption is stable lifecycle code 4, allowing a playback adapter to cancel already scheduled audio immediately. Lifecycle code 6 means an actual completed resume; an ordinary provider handle update is continuity metadata and is acknowledged internally rather than mislabeled as a resume. Audio input sequences must be strictly monotonic from one; malformed, oversized, duplicate, and out-of-order frames fail without entering the Live ingress queue. The bridge unsubscribes when stopped or when its owner dies. The stream-end frame above is for automatic activity detection. With manual activity detection, use client activity-start/end frames instead and do not send stream-end; the provider configuration deliberately rejects mixing the two modes.

Function calling is opt-in. Declaring a provider tool does not authorize its execution. To run it automatically, configure a trusted adk_live_tool_executor module, an exact non-empty allowlist, and either a sequential or bounded concurrent policy. The repository example delegates only get_weather to the already checked weather tool:

{ok, readme_weather_tool} =
c("examples/readme_weather_tool.erl"),
{ok, readme_live_weather_executor} =
c("examples/readme_live_weather_executor.erl"),
WeatherDeclaration =
#{type => function,
name => <<"get_weather">>,
description => <<"Return weather for one city">>,
parameters =>
#{<<"type">> => <<"object">>,
<<"properties">> =>
#{<<"city">> => #{<<"type">> => <<"string">>}},
<<"required">> => [<<"city">>],
<<"additionalProperties">> => false}},
LiveToolConfig =
LiveConfig#{
provider_config =>
(maps:get(provider_config, LiveConfig))#{
tools => [WeatherDeclaration]},
tool_execution =>
#{enabled => true,
executor => readme_live_weather_executor,
policy => sequential,
allowed_tools => [<<"get_weather">>],
options => #{}}}.

Without tool_execution, the application receives a tool_call event and must explicitly return the correlated result with live_send_tool_response/5. Tool calls are never discovered from an ordinary agent catalog and are never executed merely because the model named them.

Resumption uses only the newest provider handle. On disconnect, unsent text, audio, video, and tool responses are dropped and reported; they are never replayed because the remote side might already have applied them. Subscriber delivery is also future-only: credit/ack bounds memory while connected, but a new subscriber or Last-Event-ID does not replay prior Live media/events. GoAway initiates a bounded proactive resume when the provider supplied a usable handle. Applications must tolerate a terminal session when no safe resume is available. Deterministic tests cover the codec, session state machine, transport validation, and endpoint construction, while the paid suite covers the real verified-TLS Google connection. A separate local, CA-controlled WebSocket TLS integration harness is not included yet.

To put that structured stream through sessions, plugins, stable run replay, and cancellation, select content streaming on the Runner:

{ok, ContentRunnerAgentPid} = erlang_adk:spawn_agent(
<<"ContentRunnerAgent">>,
#{provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>,
instructions => <<"Describe supplied media concisely.">>}, []),
ContentRunner = adk_runner:new(
ContentRunnerAgentPid, <<"readme_app">>, erlang_adk_session,
#{streaming_mode => content,
max_stream_output_bytes => 16777216,
run_timeout => 120000}),
{ok, ContentRunId} = adk_run:start(
ContentRunner, <<"user-1">>, <<"multimodal-session">>,
MultimodalPrompt),
{completed, FinalContent} = adk_run:await(ContentRunId, 120000),
{ok, FinalContent} = adk_content:validate(FinalContent),
ok = adk_run:subscribe(ContentRunId),
DrainContentReplay = fun Loop(Id, Events) ->
receive
{adk_run_event, Id, _Sequence, Event} ->
Loop(Id, [adk_event:to_map(Event) | Events]);
{adk_run_terminal, Id, _Sequence, Outcome} ->
{lists:reverse(Events), Outcome}
after 120000 ->
{error, replay_timeout}
end
end,
{ContentEvents, {completed, FinalContent}} =
DrainContentReplay(ContentRunId, []),
[_ | _] = ContentEvents,
ok = adk_run:unsubscribe(ContentRunId),
ok = erlang_adk:stop_agent(ContentRunnerAgentPid).

subscribe/1 and await/2 are independent mailbox protocols. A subscriber must drain through adk_run_terminal and unsubscribe (or terminate and let its monitor clean up); merely calling await/2 does not consume replay messages.

Streaming function-call parts remain control data: they do not appear as provisional display content, and the Runner continues through its ordinary correlated tool-call/tool-response rounds. A provider-originated function_response part is rejected because function responses belong to the tool/user side of the conversation.

Gemini streaming

Streaming invokes the callback once per decoded text delta rather than once with a buffered raw SSE body:

StreamConfig = #{provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>},
StreamHistory = [
#{role => system, content => <<"Be concise.">>},
#{role => user, content => <<"Explain OTP in two sentences.">>}
],
ChunkCallback = fun(Chunk) -> io:format("~ts", [Chunk]) end,
StreamResult = adk_llm:stream(
StreamConfig, StreamHistory, [], ChunkCallback),
case StreamResult of
ok -> io:format("~n");
{tool_calls, StreamCalls} -> io:format("tool calls: ~p~n", [StreamCalls]);
{error, StreamReason} -> io:format("stream failed: ~p~n", [StreamReason])
end.

Both regular and streaming Gemini requests validate HTTP status codes, use x-goog-api-key, verify TLS certificates/hostnames, send parts as arrays, and preserve thought signatures plus function-call IDs.

MCP clients and Streamable HTTP server

connect/2 performs the MCP initialize handshake before returning. This repository-verifiable example uses the included line-delimited JSON-RPC fixture; replace the command with your own stdio MCP server in production:

The implementation targets the official MCP 2025-11-25 lifecycle and Streamable HTTP transport.

FixtureCommand = unicode:characters_to_binary(
filename:absname("test/mcp_stdio_fixture.sh")),
{ok, McpClient} = adk_mcp_client:connect(
<<"stdio">>, FixtureCommand),
{ok, [McpTool]} = adk_mcp_client:list_tools(McpClient),
<<"search">> = maps:get(<<"name">>, McpTool),
{ok, McpResult} = adk_mcp_client:execute_tool(
McpClient, <<"search">>, #{<<"query">> => <<"erlang">>}),
false = maps:get(<<"isError">>, McpResult),
ok = adk_mcp_client:close(McpClient).

The 2025-11-25 Streamable HTTP client and server retain the same lifecycle but add negotiated protocol/session headers. Each application-managed client is a temporary child of adk_mcp_client_sup; independent MCP sessions therefore do not share a mailbox or failure domain. This loopback example is also executed by readme_examples_test:

Token = <<"replace-with-at-least-16-secret-bytes">>,
Resource = #{uri => <<"memo://otp">>, name => <<"otp-note">>,
mime_type => <<"text/plain">>,
read => fun() ->
{ok, <<"Supervise every long-lived process.">>}
end},
Prompt = #{name => <<"explain">>,
arguments => [#{<<"name">> => <<"topic">>,
<<"required">> => true}],
get => fun(#{<<"topic">> := Topic}) ->
{ok, #{<<"messages">> =>
[#{<<"role">> => <<"user">>,
<<"content">> =>
#{<<"type">> => <<"text">>,
<<"text">> => <<"Explain ", Topic/binary>>}}]}}
end},
{ok, McpServer} = adk_mcp_server:start(
<<"streamable_http">>,
#{ip => {127, 0, 0, 1}, port => 0, auth_token => Token,
tools => [readme_weather_tool],
resources => [Resource], prompts => [Prompt]}),
{ok, #{url := McpUrl}} = adk_mcp_server:endpoint(McpServer),
AuthFun = fun() ->
[{<<"authorization">>, <<"Bearer ", Token/binary>>}]
end,
{ok, HttpMcpClient} = adk_mcp_client:connect(
<<"streamable_http">>, McpUrl,
#{auth_fun => AuthFun, allow_http_loopback => true}),
{ok, McpToolset} = adk_toolset:new(adk_mcp_client, HttpMcpClient),
{ok, [_]} = adk_toolset:expand_tools([McpToolset]),
{ok, [_]} = adk_mcp_client:list_tools(HttpMcpClient),
{ok, WeatherResult} = adk_mcp_client:execute_tool(
HttpMcpClient, <<"get_weather">>, #{<<"city">> => <<"Pune">>}),
false = maps:get(<<"isError">>, WeatherResult),
{ok, [_]} = adk_mcp_client:list_resources(HttpMcpClient),
{ok, #{<<"contents">> := [_]}} = adk_mcp_client:read_resource(
HttpMcpClient, <<"memo://otp">>),
{ok, [_]} = adk_mcp_client:list_prompts(HttpMcpClient),
{ok, #{<<"messages">> := [_]}} = adk_mcp_client:get_prompt(
HttpMcpClient, <<"explain">>, #{<<"topic">> => <<"OTP">>}),
ok = adk_mcp_client:close(HttpMcpClient),
ok = adk_mcp_server:stop(McpServer).

Pass McpToolset in an agent's tool list to make discovered MCP schemas model-visible. adk_mcp_client:resolved_call/4 produces the same bounded call descriptor used by local and OpenAPI toolsets; invocation context is not sent to the MCP server.

The server binds to loopback and assigns bounded, expiring sessions by default. Each session is bound to the SHA-256 scope of the authenticated principal; a POST or DELETE using another principal receives the same 404 as an unknown session. A non-loopback bind requires authentication, the explicit allow_non_loopback => true acknowledgement, and either direct Cowboy TLS via tls_options or trusted_tls_proxy => true. Use the proxy acknowledgement only when the clear listener is network-reachable exclusively from that trusted TLS terminator; forwarding headers are not accepted as proof of TLS. Loopback may remain clear for development, but the client must opt in with allow_http_loopback => true as the example does. Configure auth_token (stored only as SHA-256 in listener state) or an auth_fun/1 receiving request method/endpoint/origin/peer plus the transient Authorization header. A successful production hook returns {ok, PrincipalId}. Use authorization_fun/3 for exact operation/resource policy; it receives the credential-free authentication context, the MCP method (for example <<"tools/call">>), and a bounded resource descriptor. Also configure exact allowed_origins, max_body_bytes, max_response_bytes, max_concurrency, request_timeout, max_sessions, session_ttl_ms, callback_timeout, and callback_max_heap_words before exposing it. Authentication and authorization hooks run in separate monitored, heap-limited workers and fail closed on timeout, crash, or oversize result. HTTP bearer/OAuth acquisition remains an application concern; the client accepts a zero-arity auth_fun so a token manager can supply fresh authorization headers without persisting a token in the MCP worker state. In production that callback must capture only an opaque token-manager reference, never the credential itself; MCP client/server status formatting suppresses callback internals and authentication messages.

The HTTP client accepts HTTPS by default, resolves every address before connecting, pins one validated address while retaining the original Host/SNI, rejects redirects and private/reserved destinations, and applies one absolute deadline across authentication, connect, response headers, and body chunks. Use allowed_hosts to constrain public targets and allowed_private_hosts only for exact private HTTPS service names. Static credential headers, cookies, and Proxy-Authorization are rejected; auth_fun/0 may return at most one origin Authorization header. These rules prevent a discovered or redirected endpoint from receiving credentials meant for another origin. Client authentication and custom resolver_fun/1 callbacks run in monitored off-heap workers under the operation's absolute deadline; set callback_max_heap_words to cap each worker and max_resolved_addresses to cap the validated DNS set. Server authentication, authorization, tool, resource, and prompt callbacks use the corresponding callback_max_heap_words boundary, and only normalized responses within max_response_bytes cross back into the long-lived server process. Completion timestamps reject results that were queued after the deadline, process aliases suppress late replies, and owner watchdogs reap work when its client, server, or Cowboy request process dies.

For OAuth-protected MCP, set oauth_protected_resource with the exact resource, one or more HTTPS authorization_servers, scopes_supported, required_scopes, and the externally authoritative HTTPS resource_metadata_url. The server publishes the unauthenticated RFC 9728 document at metadata_path and includes resource_metadata, scope, and insufficient_scope details in Bearer challenges. This mode deliberately requires both auth_fun/1 and authorization_fun/3; metadata without actual authentication and operation authorization is rejected at startup.

This server returns JSON for POST and intentionally returns HTTP 405 for the optional unsolicited GET/SSE channel. The client accepts JSON and bounded SSE responses to POST. SSE resumability, resource subscriptions/templates, notifications, roots, sampling, elicitation, completion, client-side OAuth authorization-server discovery/PKCE, and a server-side stdio transport remain explicit limitations. Server-side RFC 9728 protected-resource discovery and challenges are implemented. Endpoint URLs containing user info, query strings, or fragments are rejected so bearer credentials cannot accidentally become request URLs. Deprecated HTTP+SSE (<<"sse">>) fails with {error, {unsupported_transport, sse_deprecated_use_streamable_http}}. The client and server also accept the finalized 2025-06-18 revision during version negotiation, while initiating new connections with 2025-11-25. If a client proposes an unsupported initialization version, the server negotiates the latest supported version as MCP requires. If Streamable HTTP reports a lost/expired session, the client reinitializes and automatically retries only read-only protocol operations (tools/list, resources/list, resources/read, prompts/list, prompts/get, and ping). It never automatically replays tools/call or an unknown/mutating method whose first attempt may already have caused an effect; that call returns {error, {mcp_session_lost, request_not_replayed}}, and a later explicit application call uses the renewed session. Per-client HTTP request serialization and a separately bounded pending-request queue remain limitations; use independent supervised MCP clients for concurrency and failure isolation.

Runner plugins and observability

Plugins are ordered, Runner-global lifecycle policy. They follow the same observable ordering as Google ADK plugins while using a bounded BEAM worker for every hook: global plugin hooks run before the corresponding agent callback; an amendment flows into it, while an early return or halt skips it. Direct erlang_adk:prompt/2 calls continue to use agent-local callbacks only.

The repository includes the observer and exporter used below. From ./rebar3 shell:

{ok, readme_policy_plugin} = c("examples/readme_policy_plugin.erl"),
{ok, readme_observability_exporter} =
c("examples/readme_observability_exporter.erl"),
{ok, ObservedAgent} = erlang_adk:spawn_agent(
<<"ObservedAgent">>,
#{provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>}, []),
PolicyPlugin =
#{id => <<"audit-policy">>,
module => readme_policy_plugin,
mode => observe,
failure_policy => closed,
timeout_ms => 1000,
max_heap_words => 100000,
config => #{notify => self()}},
ObservationExporter =
#{id => <<"console-observer">>,
module => readme_observability_exporter,
failure_policy => open,
timeout_ms => 1000,
max_heap_words => 100000,
config => #{target => self()}},
ObservedRunner = adk_runner:new(
ObservedAgent, <<"observed-app">>, erlang_adk_session,
#{plugins => [PolicyPlugin],
observability =>
#{exporters => [ObservationExporter],
capture_content => false,
attributes => #{environment => <<"development">>}}}),
{ok, ObservedReply} = adk_runner:run(
ObservedRunner, <<"user-1">>, <<"observed-session">>,
<<"Explain OTP supervision in one sentence.">>),
io:format("~ts~n", [ObservedReply]),
receive
{adk_plugin, before_run, PluginContext} ->
true = is_binary(maps:get(<<"run_id">>, PluginContext))
after 1000 ->
erlang:error(plugin_notification_timeout)
end,
receive
{adk_observation, ObservationEnvelope} ->
false = maps:get(<<"content_captured">>, ObservationEnvelope)
after 1000 ->
erlang:error(observation_timeout)
end,
ok = erlang_adk:stop_agent(ObservedAgent).

A plugin implements any subset of adk_plugin's on_user_message, run/agent/model/tool before/after hooks, on_event, on_model_error, on_tool_error, on_agent_error, and on_run_error. The older on_error hook remains a compatibility fallback for phase-specific notifications.

Descriptors are compiled once in list order. An observation plugin may return only observe, continue, or ok. An intervention plugin has three distinct control results:

An early return is therefore not an amendment. on_model_error and on_tool_error can recover their phase through the normal intervention results. on_agent_error, on_run_error, and success-only after_run are best-effort notifications, so their intervention results are ignored. Duplicate IDs, unavailable modules, invalid limits, and unknown modes are rejected by adk_runner:new/4. Every callback has a monitored timeout, heap limit, result-byte limit, owner monitor, and late-reply suppression. failure_policy => open records a structural failure and proceeds; closed stops the run. Hook contexts are immutable, secret-pruned maps. Event amendments cannot alter identity, state/actions, continuations, finality, or already-validated final content.

A compiled pipeline accepts at most 128 plugins; IDs are non-empty binaries of at most 256 bytes. Descriptor timeouts are capped at 120 seconds, callback heaps at 10,000,000 words, and callback results/configuration at 1 MiB each. These are hard validation bounds, not suggested operational defaults.

The built-in plugins are ordinary descriptors and remain explicit policy:

Use mode => intervene for the first three because they can amend or return a phase value, and mode => observe for the metadata logger. For example, a global-instruction descriptor uses #{id => <<"global-policy">>, module => adk_plugin_global_instruction, mode => intervene, failure_policy => closed, config => #{instruction => <<"Answer concisely.">>, position => prepend}}.

Stateful plugins use one supervised actor per configured instance. Calls are serialized, queued under a fixed cap, and executed in deadline/heap-bounded workers against an immutable state snapshot. State commits only when the owner remains alive and the callback completed before its deadline:

{ok, readme_stateful_counter_plugin} =
c("examples/readme_stateful_counter_plugin.erl"),
{ok, CounterInstance} = adk_plugin_runtime_sup:start_instance(
#{id => <<"readme-counter">>,
module => readme_stateful_counter_plugin,
config => #{notify => self()},
max_queue => 32,
max_heap_words => 100000,
max_state_bytes => 4096}),
StatefulPlugin =
#{id => <<"stateful-counter">>,
module => adk_plugin_stateful_adapter,
mode => observe,
failure_policy => closed,
timeout_ms => 1000,
max_heap_words => 100000,
config => #{instance => CounterInstance, timeout_ms => 800}},
{ok, StatefulPipeline} = adk_plugin_pipeline:compile([StatefulPlugin]),
{continue, #{}, _StatefulTrace} = adk_plugin_pipeline:run(
StatefulPipeline, before_run, #{}, #{}),
receive
{stateful_before_run, 1} -> ok
after 1000 ->
erlang:error(stateful_plugin_timeout)
end,
ok = adk_plugin_runtime_sup:stop_instance(CounterInstance).

The returned PID is the instance identity. Its supervisor child is deliberately temporary: an unexpected crash makes that identity unavailable instead of silently restarting with empty state behind a stale PID. Initialization itself runs in a separately monitored timeout/heap-bounded worker. Applications that need restart or durable state must recreate the instance explicitly or supply a persistent plugin-state adapter.

Stateful instance IDs/configuration are capped at 256 bytes/1 MiB; queue, worker-heap, state, initialization-timeout, and invocation-timeout values have hard maxima of 4096 entries, 10,000,000 words, 64 MiB, 30 seconds, and 120 seconds respectively. A callback completion queued before an owner-monitor DOWN is still discarded when that owner has already died; owner liveness is part of the state-commit condition, not only a condition for sending a reply.

Runner observability is metadata-only by default. Set observability => disabled to turn it off. Legacy lifecycle envelopes and telemetry events share trace, span, run, invocation, session, agent, model, tool, and call IDs; schema-v2 operation spans add Unix-nanosecond timestamps and monotonic durations at the actual model/tool/Live boundary. The pinned adk_genai_semconv:mapping_version/0 identifies the Development GenAI mapping used by this release.

Prompt, response, media, tool arguments, and tool results are excluded by default. capture_content => true applies only to the legacy envelope path; the v2 GenAI mapper and low-cardinality metrics never accept content. Opaque BEAM terms degrade to metadata-only diagnostics instead of breaking an invocation.

For synchronous delivery, put bounded exporter descriptors in the Runner. For asynchronous delivery, start the supervised central bus and configure the Runner with delivery => async; per-Runner exporters are rejected in that mode because retry, queue, and drop policy belong to the bus. This example sends completed spans and v1 lifecycle logs to an OTLP/HTTP JSON collector:

_ = application:stop(erlang_adk),
OtlpExporter =
#{id => <<"local-otlp">>,
module => adk_otlp_http_json_exporter,
failure_policy => open,
timeout_ms => 5000,
max_heap_words => 250000,
config =>
#{endpoint => <<"http://127.0.0.1:4318">>,
allow_private_hosts => true,
timeout_ms => 3000}},
ok = application:set_env(erlang_adk, observability_bus_enabled, true),
ok = application:set_env(
erlang_adk, observability_bus_options,
#{exporters => [OtlpExporter],
max_queue_events => 4096,
max_queue_bytes => 16777216,
max_event_bytes => 262144,
batch_size => 32,
max_inflight_batches => 2,
max_attempts => 3,
drop_policy => reject}),
{ok, _} = application:ensure_all_started(erlang_adk),
AsyncObservability =
#{delivery => async,
bus => adk_observability_bus,
failure_policy => open,
capture_content => false,
attributes => #{environment => <<"development">>}}.

Pass that map as #{observability => AsyncObservability} (alongside any other Runner options) in adk_runner:new/4; do not also put exporter descriptors in the per-Runner asynchronous configuration.

The exporter never follows redirects and does not retry by itself. It labels failures transient or permanent; the bus owns bounded delayed retries only for eligible transient failures. Delivery is bounded best effort, not a durable queue: a retry may duplicate a batch and exhausted retries are explicitly dropped and counted. A collector should deduplicate by trace/span/event identity. HTTP is appropriate only for a loopback collector; use verified HTTPS for a remote OTLP endpoint. Do not put bearer credentials in README/config files.

Inbound and outbound HTTP adapters can continue strict W3C traceparent and bounded tracestate without adding an OpenTelemetry SDK dependency:

{ok, InboundTrace} = adk_observability:from_headers(
#{<<"traceparent">> =>
<<"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01">>},
#{agent => <<"WeatherAgent">>}),
{ok, OutboundHeaders} = adk_observability:inject_headers(
InboundTrace, #{<<"accept">> => <<"application/json">>}).

Trace identifiers are correlation, never authorization. The built-in metrics registry bounds both instruments and label series and folds overflow into an explicit overflow series. adk_observability_metrics:snapshot/0 and adk_observability_bus:stats/0 are operational snapshots, not billing-grade durable counters.

See plugins, observability, and evaluation for lifecycle precedence, intervention boundaries, and adapter contracts.

Evaluation

adk_eval_set provides versioned, JSON-safe, multi-turn evaluation. Schema v2 evaluates a complete case, supports repeated independent samples, and records strict success/error/not-evaluated accounting. Cases and samples overlap only up to their configured bounds; turns inside one sample stay ordered.

The production adk_eval_agent_adapter creates a fresh supervised agent, Runner session, and cleanup guardian for every case/sample, so one evaluation does not leak conversation state into another. The built-in full-case criteria are exact_response, trajectory_exact, trajectory_in_order, trajectory_any_order, and trajectory_subset; trajectory arguments may be matched exactly, as a subset, or ignored. Custom per-turn metrics and full-case metric/judge adapters remain supported.

This paid example uses the REST model and two isolated samples:

{ok, EvalSet} = adk_eval_set:validate(
#{schema_version => 2,
id => <<"exact-dialogue">>,
version => <<"1">>,
cases =>
[#{id => <<"two-turn-case">>,
turns =>
[#{id => <<"first">>,
input => <<"Reply with exactly: ERLANG">>,
expected_response => <<"ERLANG">>},
#{id => <<"second">>,
input => <<"Again reply with exactly: ERLANG">>,
expected_response => <<"ERLANG">>}]}]}),
EvalTarget =
#{name => <<"EvalSetAgent">>,
config =>
#{provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>,
instructions =>
<<"Follow the requested output format exactly.">>},
tools => [],
runner_options => #{}},
EvalAdapter = #{module => adk_eval_agent_adapter,
target => EvalTarget, config => #{}},
EvalMetrics = [#{id => <<"exact-response">>,
criterion => exact_response,
threshold => 1.0,
config => #{normalization => exact}}],
{ok, EvalSetResult} = adk_eval_set:run(
EvalAdapter, EvalSet, EvalMetrics,
#{sample_count => 2,
concurrency => 1,
sample_concurrency => 2,
pass_rate_threshold => 1.0,
sample_pass_rate_threshold => 1.0,
min_successful_samples => 2,
capture_events => true,
capture_tool_content => false}),
true = maps:get(<<"passed">>, EvalSetResult),
{ok, SavedEvalResult} = adk_eval_set:encode_result(EvalSetResult),
SavedEvalResult = jsx:decode(jsx:encode(SavedEvalResult), [return_maps]).

An evaluation adapter implements the run_turn/5 callback in adk_eval_adapter; optional init_case/4 and terminate_case/3 callbacks own per-sample lifecycle. It may return canonical ADK events, allowing tool-call and tool-response trajectories to be captured without exposing their content by default. Metrics and LLM-backed judges share the same checked result accounting; kind => judge records the distinction without coupling the core evaluator to a provider. adk_eval_llm_judge is the explicit first-party rubric adapter. It defaults to adk_llm_gemini and gemini-3.1-flash-lite, requires a rubric identity and version, requests structured JSON, and turns provider, timeout, schema, or out-of-range-score failures into a failing judge result rather than a pass. It is never selected implicitly.

This paid continuation uses the EvalAdapter and EvalSet above:

RubricJudge =
#{id => <<"rubric-quality">>,
kind => judge,
module => adk_eval_llm_judge,
scope => 'case',
threshold => 0.8,
config =>
#{rubric_id => <<"concise-correct-answer">>,
rubric_version => <<"1">>,
rubric =>
<<"Score 1 for a correct, direct answer that follows the "
"requested format; score 0 otherwise.">>,
provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>,
request_timeout_ms => 30000}},
{ok, RubricResult} = adk_eval_set:run(
EvalAdapter, EvalSet, [RubricJudge],
#{sample_count => 1, concurrency => 1, sample_concurrency => 1}),
[RubricSummary] = maps:get(<<"metrics">>, RubricResult),
<<"rubric-quality">> = maps:get(<<"metric_id">>, RubricSummary).

Each judge request is monitored, deadline- and heap-bounded, and remains concurrent with other bounded sample workers; there is no global judge process. Provider-module injection and provider_config are trusted Erlang configuration for applications and deterministic tests, not browser, dataset, or CLI input. The successful metadata records judge schema, provider, requested model, rubric ID/version, and a bounded rationale. Provider credentials and raw provider errors are excluded and secret-bearing input keys are pruned. The rationale is still evaluation content stored in reports and may summarize other case data, so protect reports according to the evaluated data's policy. User/environment simulation and managed evaluation services remain explicit adapters with their own credentials and cost policy.

Empty criteria fail by default; use empty_criteria => pass only when an intentional execution-only evaluation should pass without scoring. Overall timeouts, per-case timeouts, heap bounds, input depth/size, sample count, concurrency, captured data, and final report bytes are all bounded. A timed-out or killed sample cannot orphan its adapter-owned agent.

Saved v2 results can be compared with per-metric tolerances and a maximum pass rate drop, then rendered deterministically:

{ok, Comparison} = adk_eval_set:compare(
BaselineEvalResult, EvalSetResult,
#{max_pass_rate_drop => 0.05,
metric_tolerances => #{<<"exact-response">> => 0.0}}),
{ok, MarkdownReport} = adk_eval_set:report(Comparison, markdown),
io:format("~ts", [MarkdownReport]).

The packaged CLI runs the same v2 engine. It defaults to built-in exact response scoring when --criteria is omitted, emits JSON to stdout by default, exits 0 on a passing gate, 2 on a completed failing gate, and 1 for invalid input/runtime errors:

./rebar3 escriptize
_build/default/bin/adk eval run \
--config examples/agent.json \
--eval-set /path/to/eval-set-v2.json \
--criteria /path/to/eval-criteria-v2.json \
--samples 2 \
--concurrency 2 \
--sample-concurrency 2 \
--baseline previous-eval-result.json \
--max-pass-rate-drop 0.05 \
--format markdown \
--output eval-report.md

--baseline expects a saved adk_eval_set result, not a Markdown report. --metric-tolerances accepts a JSON object file keyed by metric ID. Never put an API key in agent/evaluation JSON; provider credentials come from the process environment or a trusted provider profile.

The compatibility API remains available for small, single-turn datasets.

adk_eval runs rows against an existing agent and applies a metric function:

{ok, EvalPid} = erlang_adk:spawn_agent(
<<"EvalAgent">>,
#{provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>,
instructions => <<"Follow the requested output format exactly.">>}, []),
Dataset = [
#{input => <<"Reply with exactly: ERLANG">>,
expected => <<"ERLANG">>, metadata => #{case_id => 1}}
],
ExactMetric = fun(Expected, Actual) ->
case unicode:characters_to_binary(Actual) =:= Expected of
true -> 1.0;
false -> 0.0
end
end,
{ok, EvalReport} = adk_eval:run(
EvalPid, Dataset, ExactMetric,
#{concurrency => 1, timeout => 60000}),
io:format("~p~n", [EvalReport]),
ok = erlang_adk:stop_agent(EvalPid).

Rows share the supplied stateful agent and therefore share its conversation history. Evaluator worker concurrency does not make one gen_server process concurrent; use separate agents/sessions when cases must be isolated. The timeout is enforced by monitored evaluator workers for both sequential and parallel batches.

Retry, artifacts, memory, and context lifecycle

A deterministic retry example:

AttemptCounter = atomics:new(1, []),
Flaky = fun() ->
case atomics:add_get(AttemptCounter, 1, 1) of
Attempt when Attempt < 3 -> {error, temporary};
_ -> {ok, recovered}
end
end,
{ok, recovered} = adk_retry:execute(
Flaky,
#{max_attempts => 5, initial_delay => 1,
max_delay => 10, backoff_factor => 2.0}).

Each attempt runs in a fresh monitored lightweight process. attempt_timeout, one absolute total timeout/deadline, a callback heap limit, bounded backoff, and optional jitter => full prevent a crashing or blocked attempt from escaping the retry budget. For caller-detached application work, put the complete retry operation inside an adk_task or supervised run rather than using retry as a process-lifecycle substitute.

The version-2 memory contract requires an exact application/user scope. The ETS adapter performs deterministic lexical-overlap ranking with exact metadata filtering; use the Mnesia adapter for durable local storage. Neither is a vector database. Runner retrieval and ingestion are separate opt-ins, so merely configuring a service cannot silently change a prompt:

{ok, MemoryPid} = adk_memory_ets:start_link(#{}),
MemoryScope = {user, <<"readme_app">>, <<"user-1">>},
{ok, MemoryEntry} = adk_memory_ets:add_entry(
MemoryPid, MemoryScope,
#{content => <<"OTP supervision trees restart failed children">>,
metadata => #{<<"topic">> => <<"otp">>},
provenance => #{session_id => <<"memory-session">>,
author => <<"user">>}},
#{idempotency_key => <<"memory-session:otp-fact">>}),
{ok, [MemoryHit]} = adk_memory_ets:search(
MemoryPid, MemoryScope, <<"supervision restart">>,
#{filter => #{<<"topic">> => <<"otp">>}, limit => 5}),
MemoryId = maps:get(id, MemoryEntry),
MemoryId = maps:get(id, MemoryHit),
{ok, MemoryAgentPid} = erlang_adk:spawn_agent(
<<"MemoryAgent">>,
#{provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>,
instructions =>
<<"Use relevant retrieved memory only as reference data.">>},
[adk_load_memory_tool]),
MemoryRunner = adk_runner:new(
MemoryAgentPid, <<"readme_app">>, erlang_adk_session,
#{memory_svc => {adk_memory_ets, MemoryPid},
memory_retrieval =>
#{limit => 5, filter => #{<<"topic">> => <<"otp">>},
max_hit_bytes => 16384, max_total_bytes => 65536,
on_error => fail},
memory_ingestion => on_success,
service_timeout => 5000}),
{ok, MemoryAwareResponse} = adk_runner:run(
MemoryRunner, <<"user-1">>, <<"memory-session">>,
<<"What restarts children?">>),
io:format("~ts~n", [MemoryAwareResponse]),
ok = adk_memory_ets:delete_entry(MemoryPid, MemoryScope, MemoryId),
ok = erlang_adk:stop_agent(MemoryAgentPid),
ok = adk_memory_ets:stop(MemoryPid).

Preloaded entries are sorted, delimited as untrusted reference data, and added only to one invocation context; preloading does not copy them into session history. The built-in load_memory tool declares only memory_search and can perform a bounded model-selected query. As with any model-selected tool, its bounded public response is stored in the correlated tool event; it contains content, ID, score/type, and timestamp, but omits adapter metadata, provenance, and service handles. memory_ingestion => on_success admits sanitized idempotent event batches after the final event and returns without waiting for adapter completion; that shorthand queue is process-local. A separate bounded Mnesia outbox provides restart-safe admission, bounded stable-adapter resolution, checkpoints, and lease-owned idempotent at-least-once processing. Immediately before an adapter mutation it renews and revalidates the current claim; a stale owner cannot start that mutation. This is not an adapter-side generation fence, so retries still rely on the v2 adapter's stable event-ID idempotency. Enable it before application start and select memory_ingestion => #{mode => durable, adapter_id => <<"...">>, max_attempts => 5} on the Runner. Runner construction fails when that durable runtime is unavailable, and an admission failure is returned to the caller after the final session event has been persisted. See the memory guide for the exact boundary and operational status API.

Entry, session, and user erasure are exact-scope operations. See scoped long-term memory for Mnesia setup, ingestion durability, deadlines, provenance, and current limits.

The direct ETS/Mnesia adapters serialize ranking and storage within one service process. Use adk_memory_sharded when unrelated user scopes should overlap: it owns one stable supervised adapter worker per exact {user, App, User} scope, bounds active scopes and router admission, and defaults to ETS or can wrap adk_memory_mnesia. Same-scope calls remain ordered. An independent guard releases cold-route admission when a caller dies or times out and prevents its queued stale request from creating an abandoned shard. Capacity reported by this wrapper is per shard (global_quota => false), so deployments needing a global tenant budget must enforce it in admission or a custom adapter.

Artifacts are immutable binary versions scoped to an application, user, or session. The ETS adapter is useful for tests and one-node development:

{ok, ArtifactPid} = adk_artifact_ets:start_link(#{}),
ArtifactScope =
{session, <<"readme_app">>, <<"user-1">>, <<"artifact-session">>},
{ok, #{version := 1, digest := Digest}} = adk_artifact_ets:put(
ArtifactPid, ArtifactScope, <<"reports/result.txt">>, <<"first">>,
#{mime_type => <<"text/plain">>,
metadata => #{<<"source">> => <<"readme">>}}),
{ok, #{version := 2}} = adk_artifact_ets:put(
ArtifactPid, ArtifactScope, <<"reports/result.txt">>, <<"second">>, #{}),
{ok, LatestArtifact} = adk_artifact_ets:get(
ArtifactPid, ArtifactScope, <<"reports/result.txt">>, latest),
<<"second">> = maps:get(data, LatestArtifact),
{ok, #{scope := ArtifactScope,
items := [<<"reports/result.txt">>], next_cursor := undefined}} =
adk_artifact_ets:list_names(ArtifactPid, ArtifactScope, #{limit => 100}),
io:format("sha256=~ts~n", [Digest]),
ok = adk_artifact_ets:stop(ArtifactPid).

Pass {adk_artifact_ets, ArtifactPid} as Runner option artifact_svc. A new local tool declares only the operations it needs through context_capabilities/0 and calls adk_context:save_artifact/4, load_artifact/3, list_artifacts/2, list_artifact_versions/3, or delete_artifact/3; it never receives the service PID. Successful mutations produce metadata-only context_effects in the correlated tool event.

Adding adk_load_artifacts_tool allows model-selected attachment. Runner verifies the exact scoped version, MIME type, size, and digest and injects its bounded adk_content parts into only the next model request. Artifact bytes are not copied into ordinary history, state, events, or developer diagnostics.

For durable one-node storage, use the filesystem adapter. Logical scope/name values are SHA-256-addressed rather than used as path components, and every read verifies stored scope, name, version, byte count, and digest:

ArtifactRoot = filename:join(
os:getenv("TMPDIR", "/tmp"),
"erlang-adk-readme-artifacts-" ++
integer_to_list(erlang:unique_integer([positive, monotonic]))),
{ok, FsArtifactPid} = adk_artifact_fs:start_link(
#{root => ArtifactRoot, max_artifact_bytes => 1048576}),
{ok, #{version := 1}} = adk_artifact_fs:put(
FsArtifactPid, ArtifactScope, <<"reports/durable.txt">>, <<"ready">>,
#{mime_type => <<"text/plain">>}),
{ok, #{data := <<"ready">>}} = adk_artifact_fs:get(
FsArtifactPid, ArtifactScope, <<"reports/durable.txt">>, latest),
ok = adk_artifact_fs:stop(FsArtifactPid),
ok = file:del_dir_r(ArtifactRoot).

Exclusive durable reservations ensure deleted or interrupted versions are never reused, including after restart or concurrent service instances. The filesystem adapter admits at most max_scan_entries div 3 lifetime versions for one scoped logical name. It also admits at most max_scan_entries div 2 lifetime scopes per root and the same number of lifetime names per scope (max_scan_entries must be at least three). Exhaustion returns artifact_version_capacity_reached, artifact_name_capacity_reached, or artifact_scope_capacity_reached before bounded listing/repair scans fail. Durable slots and version reservations are non-reuse tombstones, so deletion does not restore any lifetime capacity. Rotate to a new name, scope, or root as appropriate, or configure a larger bound before deployment. The root and generated directories must be real directories rather than symlinks. The adapter does not encrypt artifacts; use least-privilege permissions and an encrypted volume where confidentiality requires it. See Artifact services.

For artifact workloads with many independent scopes, adk_artifact_sharded provides the same service API with one stable supervised ETS or filesystem worker per exact scope. Unrelated scopes execute concurrently while one scope retains adapter ordering. The wrapper bounds active scopes and simultaneous cold-route admission; resolved scopes bypass the router through a protected ETS route. A guard monitors each unresolved caller, releases its permit on death or timeout, and prevents an already queued stale request from creating an abandoned shard. Filesystem shards use deterministic path-safe subroots. Its quotas are per shard rather than globally aggregated. See the artifact guide for setup, status, persistence, and repair limitations.

Context selection, automatic-compaction, and provider-prefix-cache lifecycle APIs are documented in Context selection, compaction, and caching. Runner compaction requires a session backend implementing atomic compact_events/5 (both bundled backends do), and the prefix cache stores provider request prefixes rather than model responses. Both remain explicit Runner options rather than default behavior.

Provider cache creation is deadline-safe even at a mailbox race: immediately before installing a successful provider result, the registry synchronously rechecks every waiter's absolute deadline. Expired callers receive the configured deadline failure/bypass result; if all waiters have expired, the new provider resource is deleted through the bounded cleanup path instead of becoming an unreachable cache entry.

A2A 1.0 interoperability

The opt-in A2A endpoint implements the released A2A 1.0 JSON-RPC binding. It publishes /.well-known/agent-card.json with supportedInterfaces, serves JSON-RPC at /a2a/v1, and requires A2A-Version: 1.0 on protocol requests. The supported methods are SendMessage, SendStreamingMessage, GetTask, ListTasks, CancelTask, and SubscribeToTask. Streams use SSE, always begin with a current Task snapshot, preserve ordered event IDs, and close on a terminal task state.

Each protocol task maps to independently supervised, deadline-bounded Erlang work. The task store has explicit limits for active tasks, retained tasks, events, subscribers, and retention time. A disconnected request process does not own or cancel the work. This keeps the A2A lifecycle aligned with OTP's lightweight-process and supervision model.

Configure the public Agent Card before starting the application. This loopback example uses the default bridge to a registered Erlang ADK agent:

_ = application:stop(erlang_adk),
{ok, A2ACard} = adk_a2a_v1_card:new(
#{url => <<"http://127.0.0.1:8080/a2a/v1">>,
name => <<"Erlang poem agent">>,
description => <<"Writes short poems through A2A 1.0">>,
skills => [#{<<"id">> => <<"write-poem">>,
<<"name">> => <<"Write poem">>,
<<"description">> => <<"Writes a short requested poem">>,
<<"tags">> => [<<"writing">>, <<"poem">>]}]}),
ok = application:set_env(erlang_adk, a2a_v1_enabled, true),
ok = application:set_env(erlang_adk, a2a_v1_card, A2ACard),
ok = application:set_env(erlang_adk, a2a_v1_agent_name, <<"A2APoet">>),
ok = application:set_env(
erlang_adk, a2a_v1_server_options,
#{task_timeout => 60000,
retention_ms => 300000,
max_tasks => 1000,
max_active => 100,
max_events => 256,
max_subscribers_per_task => 64,
max_subscriber_queue => 8,
max_input_bytes => 1048576,
max_message_bytes => 524288,
max_task_bytes => 4194304,
max_event_bytes => 2097152,
max_artifact_bytes => 2097152,
max_history_bytes => 2097152,
max_history_messages => 128,
max_artifacts => 128,
max_parts_per_artifact => 256}),
ok = application:set_env(erlang_adk, a2a_v1_auth, none),
ok = application:set_env(erlang_adk, a2a_ip, {127, 0, 0, 1}),
ok = application:set_env(erlang_adk, a2a_port, 8080),
{ok, _} = application:ensure_all_started(erlang_adk),
{ok, A2AAgent} = erlang_adk:spawn_agent(
<<"A2APoet">>,
#{provider => adk_llm_gemini,
model => <<"gemini-3.1-flash-lite">>,
instructions => <<"Write concise poems.">>}, []),
{ok, RemoteCard} = adk_a2a_v1_client:discover(
<<"http://127.0.0.1:8080">>,
#{allow_http_loopback => true}),
A2AMessage = #{
<<"messageId">> => <<"poem-request-1">>,
<<"role">> => <<"ROLE_USER">>,
<<"parts">> => [#{<<"text">> =>
<<"Write a short poem about Erlang.">>,
<<"mediaType">> => <<"text/plain">>}]
},
{ok, #{<<"task">> := A2ATask}} = adk_a2a_v1_client:send(
RemoteCard, A2AMessage,
#{timeout => 65000, allow_http_loopback => true}),
<<"TASK_STATE_COMPLETED">> =
maps:get(<<"state">>, maps:get(<<"status">>, A2ATask)),
[#{<<"parts">> := [#{<<"text">> := A2APoem}]}] =
maps:get(<<"artifacts">>, A2ATask),
io:format("~ts~n", [A2APoem]),
ok = erlang_adk:stop_agent(A2AAgent).

A2A 1.0 Parts are member-discriminated: use text, arbitrary JSON data, or file content through base64 raw or an absolute url, with optional filename, mediaType, and metadata. Do not send the pre-1.0 kind field. Task states use names such as TASK_STATE_WORKING and TASK_STATE_COMPLETED, not the lowercase 0.3 values.

For a public or non-loopback deployment, use a dedicated A2A listener, the OIDC hook, matching Agent Card security declarations, explicit public-bind opt-in, and either direct TLS or an explicitly trusted TLS proxy. JwtPolicy is an adk_jwt_policy value configured with exact HTTPS issuer, audience, token type, maximum lifetime, asymmetric algorithm allow-list, time bounds, and required scopes. This direct-TLS example assumes the certificate files are readable only by the service account:

_ = application:stop(erlang_adk),
{ok, PublicA2ACard} = adk_a2a_v1_card:new(
#{url => <<"https://agent.example.com:8443/a2a/v1">>,
security_schemes =>
#{<<"company-oidc">> =>
#{<<"openIdConnectSecurityScheme">> =>
#{<<"openIdConnectUrl">> =>
<<"https://identity.example.com/",
".well-known/openid-configuration">>}}},
security_requirements =>
[#{<<"schemes">> =>
#{<<"company-oidc">> =>
#{<<"list">> => [<<"a2a.invoke">>]}}}]}),
ok = application:set_env(erlang_adk, dev_enabled, false),
ok = application:set_env(erlang_adk, a2a_enabled, false),
ok = application:set_env(erlang_adk, a2a_v1_enabled, true),
ok = application:set_env(erlang_adk, a2a_v1_card, PublicA2ACard),
ok = application:set_env(
erlang_adk, a2a_v1_auth, adk_a2a_v1_oidc_auth),
ok = application:set_env(erlang_adk, a2a_v1_jwt_policy, JwtPolicy),
ok = application:set_env(erlang_adk, a2a_ip, {0, 0, 0, 0}),
ok = application:set_env(erlang_adk, a2a_port, 8443),
ok = application:set_env(erlang_adk, a2a_allow_non_loopback, true),
ok = application:set_env(erlang_adk, a2a_v1_auth_timeout_ms, 5000),
ok = application:set_env(erlang_adk, a2a_v1_auth_max_heap_words, 300000),
ok = application:set_env(
erlang_adk, a2a_tls_options,
[{certfile, "/run/secrets/a2a-cert.pem"},
{keyfile, "/run/secrets/a2a-key.pem"}]),
{ok, _} = application:ensure_all_started(erlang_adk).

The authorization hook receives request headers transiently and returns a safe principal plus a stable principal ID. The store retains only a SHA-256 principal scope; credentials and raw headers are never placed in protocol tasks or events. Cross-principal lookup, listing, cancellation, and subscription all return the same not-found result as an unknown task. The outbound client obtains authorization headers just in time through auth_fun/0, so a token manager can rotate credentials without storing them in client state. For a secured card, pass the exact declared scheme name as auth_scheme together with auth_fun; an undeclared or missing scheme fails closed. Card discovery has a separate discovery_auth_fun, so an RPC bearer is never sent to the discovery origin by accident. Outbound requests require HTTPS, resolve and validate every address, pin the validated destination, and reject redirects and cross-origin interfaces by default. Each public client call has one absolute deadline, including its monitored, heap-limited credential callback; when a single RPC call receives a location and performs discovery itself, that deadline spans discovery and RPC. Calling discover/2 first and then passing the returned card to send/3 creates two separately bounded operations. Narrow allowed_hosts, allowed_private_hosts, allowed_interface_origins, max_extensions, max_extension_header_bytes, auth_timeout, and auth_max_heap_words for the deployment. Clear HTTP is accepted only when every resolved address is loopback and allow_http_loopback => true. Authentication and DNS workers use aliases, completion timestamps, bounded normalized results, and owner watchdogs; DNS admission stops at 64 addresses, and killing the request owner reaps the worker instead of leaving an orphan. A public listener refuses to share /dev or the legacy /a2a/prompt route. If TLS terminates at a proxy instead, set a2a_trusted_tls_proxy only when the listener is reachable exclusively from that trusted proxy; forwarded headers alone are not a trust boundary.

Current limitations are explicit: this release implements the JSON-RPC 1.0 binding, not gRPC or HTTP+JSON. Push-notification configuration and extended Agent Cards return their canonical A2A unsupported-operation errors; they and Agent Card JWS signing/verification are not implemented. Agent Card security schemes/requirements are structurally validated, but the bundled client only authenticates a requirement alternative containing one exact declared scheme; compound AND requirements fail locally as unsupported. The operator must keep the server hook's actual scheme/scopes aligned with its card—the runtime cannot infer callback semantics. Bounded required A2A-Extensions negotiation is validated. Tasks and replay buffers are node-local and bounded rather than distributed durable storage. Last-Event-ID reconnect works only inside the retained event window. A slow server-side SSE subscriber is detached at its configured mailbox ceiling and reconnects from that cursor; the bundled outbound client returns a bounded decoded event list rather than an unbounded live callback. Clear HTTP is restricted to loopback; a non-loopback startup fails closed without the explicit authentication, card, and TLS conditions above. See the official A2A 1.0 specification for the binding and data-model contract.

Legacy simple HTTP endpoint

The application can still expose POST /a2a/prompt for this project's legacy small JSON protocol. It is separate from A2A 1.0. The listener is disabled by default, supervised when enabled, and always restricted to IPv4 127/8 or IPv6 ::1; startup fails with legacy_a2a_server_must_bind_loopback for every non-loopback or wildcard address. The A2A v1 public-listener opt-ins do not weaken that legacy boundary. Listener settings are read only when the application starts. Because this repository's rebar3 shell starts erlang_adk automatically, stop the application before changing them:

_ = application:stop(erlang_adk),
ok = application:set_env(erlang_adk, a2a_enabled, true),
ok = application:set_env(erlang_adk, a2a_ip, {127, 0, 0, 1}),
ok = application:set_env(erlang_adk, a2a_port, 8080),
ok = application:set_env(erlang_adk, a2a_max_body_bytes, 1048576),
{ok, _} = application:ensure_all_started(erlang_adk),
{ok, HttpAgentPid} = erlang_adk:spawn_agent(
<<"HttpAgent">>, #{provider => adk_llm_gemini}, []),
{ok, HttpResponse} = erlang_adk_a2a_client:prompt(
"http://localhost:8080/a2a/prompt",
<<"HttpAgent">>, <<"Hello from HTTP">>),
io:format("~ts~n", [HttpResponse]),
ok = erlang_adk:stop_agent(HttpAgentPid).

This legacy endpoint is not wire-compatible with A2A and cannot be configured as a production public API. New integrations should use the authenticated A2A 1.0 endpoint above.

Integrated developer tooling

The Erlang-hosted developer console uses the same independently supervised run and Live runtimes as applications. It provides chat, event traces, bounded run replay, reconnect, cancellation, session inspection, approval/resume, Live status/text/future-only SSE, evaluation render/compare, and content-free observability snapshots without adding an Elixir or Node.js dependency. It is disabled by default and binds to loopback by default.

Set a dedicated local bearer token before starting the VM:

export ERLANG_ADK_DEV_TOKEN="replace-with-at-least-16-random-characters"

Then configure the listener bounds before the application starts. If artifact or memory routes are needed, do not start the application until the exact-scope resource provider in the following two fences is also configured:

_ = application:stop(erlang_adk),
DevLivePrincipal = <<"readme-live-user">>,
ok = application:set_env(erlang_adk, dev_enabled, true),
ok = application:set_env(erlang_adk, a2a_ip, {127, 0, 0, 1}),
ok = application:set_env(erlang_adk, a2a_port, 8080),
ok = application:set_env(
erlang_adk, dev_runner_options,
#{run_timeout => 120000,
tool_execution =>
#{mode => parallel, max_concurrency => 4, tool_timeout => 30000}}),
ok = application:set_env(
erlang_adk, dev_run_options,
#{retention_ms => 60000, max_buffered_events => 256}),
ok = application:set_env(erlang_adk, dev_max_session_results, 100),
ok = application:set_env(
erlang_adk, dev_live_principal, DevLivePrincipal),
ok = application:set_env(
erlang_adk, dev_live_credit,
#{messages => 16, bytes => 4194304}),
ok = application:set_env(erlang_adk, dev_max_resource_results, 100),
ok = application:set_env(erlang_adk, dev_diagnostic_timeout_ms, 5000),
ok = application:set_env(
erlang_adk, dev_diagnostic_context_policy,
#{max_bytes => 1048576,
max_tokens => 262144,
overflow => truncate}),
ok = application:set_env(erlang_adk, dev_sse_max_events, 128),
ok = application:set_env(erlang_adk, dev_sse_max_bytes, 1048576),
ok = application:set_env(erlang_adk, dev_sse_max_duration_ms, 300000).

The authenticated local API is:

MethodPathBehavior
GET/dev/v1/agentsDiscover live registered agents by stable name
GET/dev/v1/diagnosticsInspect context capabilities and configured artifact/memory sources without content or handles
GET/dev/v1/observabilityInspect bounded metric/export-bus snapshots; content is never returned
POST/dev/v1/evaluation/renderValidate and render one supplied v1/v2 evaluation result as bounded JSON or Markdown
POST/dev/v1/evaluation/compareCompare exact supplied baseline/current results and return a bounded checked comparison
GET/dev/v1/live/sessionsList bounded status for server-owned Live sessions matching the configured exact principal
POST/dev/v1/live/sessions/:session/textSend one bounded text input to that principal's Live session
GET/dev/v1/live/sessions/:session/eventsAttach future-only bounded Live SSE; Last-Event-ID is rejected because Live is not replayed
GET/dev/v1/context/:app/:user/:sessionExplain bounded context counts and fingerprint for one exact session; events/state are omitted
GET/dev/v1/context/:app/:user/:session/lifecycle?model=MODELInspect the latest whitelisted compaction checkpoint fields and content-free cache counts
POST/dev/v1/context/:app/:user/:session/cache/invalidateInvalidate one confirmed provider/app/user/model/policy cache scope across sessions
GET/dev/v1/artifacts/:app/:user/:sessionList a bounded page of logical names (limit, optional exclusive cursor)
GET/dev/v1/artifacts/:app/:user/:session/versionsList metadata-only versions for required name with bounded pagination
POST/dev/v1/artifacts/:app/:user/:session/deleteDelete one checked selector after an exact matching confirmation object
GET/dev/v1/memory/:app/:userInspect the scoped adapter's public capabilities and limits
POST/dev/v1/memory/:app/:user/searchSearch bounded, redacted reference memory in one exact user scope
POST/dev/v1/memory/:app/:user/eraseErase an entry, session, or user after an exact matching confirmation object
POST/dev/v1/runsStart a stable run for a registered agent
GET/dev/v1/runs/:run_idInspect status and parent/resume links
DELETE/dev/v1/runs/:run_idRequest cancellation
GET/dev/v1/runs/:run_id/eventsSSE replay/reconnect using Last-Event-ID
POST/dev/v1/runs/:run_id/resumeResume a paused run with {"tool_response": ...}
GET/dev/v1/sessions/:app/:userList bounded, newest-first session metadata
POST/dev/v1/sessions/:app/:userCreate a session with {"session_id":"..."}
GET/dev/v1/sessions/:app/:user/:sessionInspect one exact session scope
DELETE/dev/v1/sessions/:app/:user/:sessionDelete one exact session scope
POST/dev/v1/sessions/:app/:user/:session/stateApply one non-secret {"state_delta":{...}}

Artifact and memory routes need an exact-scope resource provider. This avoids a global service lookup based only on path text. The callback receives the requested scope and returns a validated service reference or denies it. Put the module in the owning application's source tree and compile it before the startup fence below:

-module(my_dev_resource_provider).
-export([resolve/3]).
resolve(#{app_name := App, user_id := User, session_id := Session,
artifact_svc := ArtifactService},
artifact, {session, App, User, Session}) ->
{ok, ArtifactService};
resolve(#{app_name := App, user_id := User,
memory_svc := MemoryService},
memory, {user, App, User}) ->
{ok, MemoryService};
resolve(_Handle, _Kind, _Scope) ->
{error, forbidden}.

For a local development VM, create fresh resource services, configure the provider, start the application, and then seed the data the commands below inspect. These processes are deliberately distinct from the short-lived memory/artifact examples above, which already stopped their services:

{ok, DevArtifactPid} = adk_artifact_ets:start_link(#{}),
{ok, DevMemoryPid} = adk_memory_ets:start_link(#{}),
{ok, DevCachePid} = adk_context_cache:start_link(
#{min_prefix_tokens => 4096,
default_ttl_ms => 300000,
failure_mode => bypass}),
{ok, DevRunnerOptions} =
application:get_env(erlang_adk, dev_runner_options),
ok = application:set_env(
erlang_adk, dev_runner_options,
DevRunnerOptions#{context_cache =>
#{cache => DevCachePid,
provider => adk_context_cache_gemini,
ttl_ms => 300000,
policy => #{purpose => developer_inspection}}}),
DevResources =
#{app_name => <<"readme_app">>,
user_id => <<"user-1">>,
session_id => <<"artifact-session">>,
artifact_svc => {adk_artifact_ets, DevArtifactPid},
memory_svc => {adk_memory_ets, DevMemoryPid}},
ok = application:set_env(
erlang_adk, dev_resource_provider,
{my_dev_resource_provider, DevResources}),
{ok, _} = application:ensure_all_started(erlang_adk),
DevArtifactScope =
{session, <<"readme_app">>, <<"user-1">>, <<"artifact-session">>},
{ok, _} = adk_artifact_ets:put(
DevArtifactPid, DevArtifactScope, <<"reports/result.txt">>, <<"ready">>,
#{mime_type => <<"text/plain">>}),
{ok, _} = adk_memory_ets:add_entry(
DevMemoryPid, {user, <<"readme_app">>, <<"user-1">>},
#{content => <<"OTP supervision restarts failed children">>,
provenance => #{session_id => <<"memory-session">>}}, #{}),
{ok, _} = erlang_adk_session:create_session(
<<"readme_app">>, <<"user-1">>,
#{session_id => <<"artifact-session">>}).

Now open http://127.0.0.1:8080/dev and enter the token in the connection panel. The token remains in page memory, is sent only in the Authorization header, and is never accepted in a query string. Cowboy route state keeps only its SHA-256 digest. Browser requests with an Origin header must be same-origin.

dev_live_principal is mandatory for developer Live access and must exactly match the Principal supplied to start_live_session/3. It is an ownership capability, not a display name or wildcard. The developer bearer does not bypass the Live session's principal check. The console discovers and controls only sessions already supervised in the same VM; it does not accept a model, API key, transport, tool executor, or principal from the browser and does not create a Live session. Start the Live session from trusted application code as shown in the Gemini Live section and leave it open while testing this panel. The browser projection omits audio/media bytes and provider signatures.

For a simple single-tenant local server, putting artifact_svc and memory_svc in dev_runner_options remains a compatibility fallback. A provider is preferred whenever different paths may resolve to different tenants. Provider handles and returned service references never appear in diagnostic output. Context diagnostics work independently through dev_session_service.

The developer boundary also validates every adapter result against the path scope. An artifact name-page envelope or version record whose embedded session scope differs from the requested app/user/session, or a memory hit whose embedded user scope differs from the requested app/user, fails closed as unavailable; it is never projected under the caller's scope.

The lifecycle endpoint reads cache configuration only from the private dev_runner_options.context_cache map used by that listener. It returns whitelisted compaction checkpoint identifiers/counts and cache lifecycle counts; it never returns events, summaries, state, policy values, PIDs, leases, provider resource names, or credentials. Its session path proves that the caller can inspect the anchor session, but a prefix cache is deliberately shared across sessions within the exact configured provider, app, user, model, and policy scope. Confirmed invalidation removes every entry and in-flight create in that scope, across TTLs and prefixes. The 64-character scope_fingerprint identifies this breadth without exposing the policy.

The developer SSE handler uses adk_run credit delivery and has at most one unacknowledged run message in its mailbox. Each connection also closes cleanly at the first configured bound: dev_sse_max_events (default 128 encoded run events), dev_sse_max_bytes (default 1 MiB of SSE bodies), or dev_sse_max_duration_ms (default five minutes). The console reconnects with the last received sequence in Last-Event-ID; sequences are not silently skipped. A cursor older than dev_run_options.max_buffered_events receives HTTP 409 run_event_replay_gap with the oldest and latest retained sequences. A buffer overrun discovered after streaming starts is an explicit event: replay_gap followed by a clean close.

All three values must be positive; validation also caps them at 10,000 events, 16 MiB, and one hour respectively, so a configuration typo cannot remove the per-connection bound.

SSE disconnect or a configured connection boundary only unsubscribes the HTTP process; it never cancels the run. Request bodies, fields, connections, keepalive, replay, delivery mailboxes, encoded stream bytes, and timeouts are bounded. Treat this as a local development surface. Do not bind it publicly or reuse its bearer token as a model key, user identity, or tool credential.

Build the integrated CLI with ./rebar3 escriptize; the executable is _build/default/bin/adk. Agent configuration is checked JSON. Secrets are rejected in the file—keep GEMINI_API_KEY in the environment. The checked-in examples/agent.json contains:

{
"name": "CliAgent",
"provider": "gemini",
"model": "gemini-3.1-flash-lite",
"instructions": "Answer concisely and follow exact-output requests.",
"global_instruction": "Never expose credentials.",
"generation_config": {
"thinking_config": {"thinking_level": "low"},
"safety_settings": [{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "BLOCK_MEDIUM_AND_ABOVE"
}]
},
"runner_options": {
"max_llm_calls": 8,
"max_tool_rounds": 4
}
}
./rebar3 escriptize
_build/default/bin/adk doctor
_build/default/bin/adk config validate examples/agent.json
_build/default/bin/adk run --config examples/agent.json \
--message "Explain rest_for_one" --user local --session cli-demo
_build/default/bin/adk console --config examples/agent.json \
--user local --session cli-console
_build/default/bin/adk evaluate --config examples/agent.json \
--dataset examples/eval.json

console is interactive until /exit or terminal EOF. serve also blocks so the supervised developer listener and its retained runs stay alive. Start it in one terminal:

export ERLANG_ADK_DEV_TOKEN="replace-with-the-same-local-token"
_build/default/bin/adk serve --config examples/agent.json \
--ip 127.0.0.1 --port 8080

The command must print "status":"listening" and then remain running. Open http://127.0.0.1:8080/dev in a browser. While serve remains running, use a second terminal for developer API commands; environment exports are terminal-local, so export the same bearer token there as well:

Plain adk serve intentionally has no browser/CLI option that invents a Live principal or creates a Live session. Its agent/run and observability panels work directly. For the Live commands below, target the OTP VM started after the trusted dev_live_principal configuration above and keep a matching Live session open.

export ERLANG_ADK_DEV_TOKEN="replace-with-the-same-local-token"
_build/default/bin/adk inspect agents --url http://127.0.0.1:8080
_build/default/bin/adk inspect diagnostics --url http://127.0.0.1:8080
_build/default/bin/adk inspect observability --url http://127.0.0.1:8080
# These two commands require the trusted dev_live_principal configuration and
# an already-supervised matching Live session; plain `adk serve` supplies neither.
_build/default/bin/adk inspect live --url http://127.0.0.1:8080
_build/default/bin/adk live send readme-live-session \
--text "Explain supervision" --url http://127.0.0.1:8080
_build/default/bin/adk inspect run RUN_ID
_build/default/bin/adk session create adk-cli local scratch
_build/default/bin/adk inspect sessions adk-cli local
_build/default/bin/adk inspect session adk-cli local scratch
_build/default/bin/adk session state adk-cli local scratch \
--delta-json '{"developer:mode":"trace"}'
_build/default/bin/adk resume RUN_ID \
--response-json '{"confirmed":true}'
_build/default/bin/adk session delete adk-cli local scratch
# The remaining resource/context commands require an owning OTP server
# configured by the Erlang startup example above. Plain `adk serve` has no
# artifact or memory service PIDs; replace it with that resource-enabled VM.
_build/default/bin/adk inspect context readme_app user-1 artifact-session
_build/default/bin/adk inspect context-lifecycle \
readme_app user-1 artifact-session --model gemini-3.1-flash-lite
_build/default/bin/adk inspect artifacts readme_app user-1 artifact-session \
--limit 100
_build/default/bin/adk inspect artifact readme_app user-1 artifact-session \
--name reports/result.txt --limit 100
_build/default/bin/adk inspect memory readme_app user-1
_build/default/bin/adk memory search readme_app user-1 \
--query "supervision restart" --limit 5

Against that resource-enabled OTP server, destructive CLI commands require the exact JSON confirmation printed in their arguments, for example:

_build/default/bin/adk artifact delete \
readme_app user-1 artifact-session reports/result.txt latest \
--confirm-json \
'{"app_name":"readme_app","user_id":"user-1","session_id":"artifact-session","name":"reports/result.txt","selector":"latest"}'
_build/default/bin/adk memory erase \
readme_app user-1 session memory-session \
--confirm-json \
'{"app_name":"readme_app","user_id":"user-1","target":"session","identifier":"memory-session"}'
# Replace SCOPE_FINGERPRINT with the exact value returned by
# `adk inspect context-lifecycle`; this invalidates the provider/app/user/
# model/policy cache scope across sessions, not only artifact-session.
_build/default/bin/adk context-cache invalidate \
readme_app user-1 artifact-session --model gemini-3.1-flash-lite \
--confirm-json \
'{"app_name":"readme_app","user_id":"user-1","session_id":"artifact-session","model":"gemini-3.1-flash-lite","scope_fingerprint":"SCOPE_FINGERPRINT"}'

Plain HTTP inspection is restricted to loopback; remote URLs must use HTTPS. The CLI emits JSON for automation and redacts operational failures. serve requires ERLANG_ADK_DEV_TOKEN and deliberately refuses a non-loopback bind. The interactive console keeps one supervised agent and Runner alive across turns. /inspect, /new, /session ID, /resume JSON, /help, and /exit are local console commands; a terminal EOF also closes it cleanly.

In the second-terminal commands, RUN_ID means a retained run created by that still-running developer server (for example through /dev); for resume it must be the ID of the paused parent run. The ID printed by the one-shot adk run command is informational after that command exits—the ephemeral CLI VM is gone, so a later process cannot inspect or resume that in-memory run.

The resume payload above is the exact contract for a pause whose details type is tool_confirmation; use {"confirmed":false} to reject it. The developer UI detects that type and fills the matching payload automatically. Other pause types, including application-defined long-running work and credential flows, retain their own validated response shape—inspect the pause details rather than sending confirmed indiscriminately.

developer_api_unavailable with connection_refused means no process is listening at the selected URL; it is not an authentication or Gemini error. Check that the first terminal is still running and that the port is free. If 8080 is occupied, choose another loopback port for serve, pass the matching --url to every inspection command, and open that same port in the browser. If diagnostics report an artifact or memory source as "unavailable", the HTTP listener is healthy but no dev_resource_provider (or compatibility service in dev_runner_options) was configured for that resource. A normal adk serve --config examples/agent.json cannot manufacture storage PIDs from JSON; start configured services in the owning OTP application and expose them through the callback above. context_cache_unavailable on lifecycle or invalidation similarly means that listener did not start with a live private cache/provider map in dev_runner_options.context_cache; it does not indicate that Gemini text generation or the session backend failed.

Authentication foundation

adk_auth_sup is part of the application supervision tree. Its private ETS credential store returns opaque references scoped by principal and provider; the token manager performs monitored, timeout-bounded, single-flight refreshes keyed by principal, provider, scopes, and audience. Expiry skew prevents a nearly expired token from being reused. Raw credentials and cached access tokens are kept out of ordinary process state, sessions, events, prompts, and status output, and known secret fields are recursively redacted.

The selected enterprise identity implementation is OpenID Connect/OAuth 2.0 through oidcc. Provider discovery and JWKS refresh are supervised and opt-in. Configure providers before application startup, using a pre-existing atom for each worker name:

_ = application:stop(erlang_adk),
ok = application:set_env(
erlang_adk, oidc_providers,
[#{name => company_oidc,
issuer => <<"https://identity.example.com">>}]),
ok = application:set_env(
erlang_adk, auth_provider_profiles,
#{<<"orders-api">> =>
#{provider_module => adk_auth_provider_oidcc,
context => #{provider_worker => company_oidc},
allowed_scopes => [<<"orders.read">>],
allowed_audiences => [<<"https://orders.example.com">>],
resource_indicator => true}}),
{ok, _} = application:ensure_all_started(erlang_adk),
{ok, JwtPolicy} = adk_jwt_policy:new(
#{issuer => <<"https://identity.example.com">>,
audience => <<"erlang-adk-api">>,
trusted_audiences => [],
signing_algs => [<<"RS256">>],
allowed_token_types => [<<"at+jwt">>],
token_use => access_token,
max_token_lifetime_seconds => 3600,
clock_skew_seconds => 30,
required_scopes => [<<"agent.run">>],
verifier_timeout_ms => 5000,
verifier_max_heap_words => 262144,
provider => company_oidc}),
{ok, Identity} = adk_jwt_policy:authenticate(
JwtPolicy, RequestHeaders),
Principal = maps:get(principal, Identity).

RequestHeaders must come from the HTTP boundary; tokens in query parameters or request bodies are not accepted. In addition to Oidcc signature/JWKS checks, the policy independently enforces HTTPS issuer equality, audience containment, an asymmetric algorithm and token-type allow-list, maximum token lifetime, expiration/not-before/issued-at, subject, scope, bounded clock skew, and a safe claim allow-list. The returned principal is issuer-bound, so identical subjects from different issuers cannot collide. The verifier callback and its normalized claims execute in a monitored, deadline- and heap-bounded worker; late, oversized, crashed, or heap-exhausted verification fails closed without retaining a reply in the caller mailbox. token_use => access_token deliberately does not compare the OIDC azp (client) claim with the API resource audience. Consumers that validate an ID token directly must select token_use => id_token; the Phoenix code exchange also validates nonce and azp against its configured OIDC client before the identity reaches the gateway.

Outbound service credentials remain scoped to that opaque principal. Load the secret outside the repository, store it once, and give tools only the opaque reference:

ClientIdString = os:getenv("ORDERS_OAUTH_CLIENT_ID"),
ClientSecretString = os:getenv("ORDERS_OAUTH_CLIENT_SECRET"),
true = is_list(ClientIdString),
true = is_list(ClientSecretString),
ClientId = unicode:characters_to_binary(ClientIdString),
ClientSecret = unicode:characters_to_binary(ClientSecretString),
{ok, CredentialRef} = adk_credential_store_ets:put(
adk_credential_store_ets, Principal, <<"orders-api">>,
#{kind => oauth_client_credentials,
client_id => ClientId,
client_secret => ClientSecret}),
{ok, OAuthToken} = adk_token_manager:get_token(
adk_token_manager,
#{principal => Principal,
provider => <<"orders-api">>,
credential_ref => CredentialRef,
scopes => [<<"orders.read">>],
audience => <<"https://orders.example.com">>},
15000),
<<"Bearer">> = maps:get(token_type, OAuthToken).

The provider module, its base context, allowed scopes/audiences, and RFC 8707 resource-indicator policy come only from the immutable operator profile loaded when adk_auth_sup starts. A request cannot replace them. Concurrent requests for the same principal/provider/scopes/audience join one monitored refresh; cache size, in-flight refreshes, and waiters per refresh are bounded. On credential deletion or revocation, call adk_token_manager:invalidate/2 with the exact principal, provider, and opaque credential reference before removing the credential. Refresh-token grants require an expected subject; when a provider rotates the refresh token, the new value is atomically persisted before the access token is released. A stale compare-and-swap or storage failure fails closed. The built-in ETS store is private and node-local; production secret-manager adapters implement adk_credential_store, including atomic compare_and_swap/6, and should be durable and encrypted.

Interactive user OAuth/OIDC uses the supervised authorization-code manager and the same durable Runner suspension contract as other long-running tools. Put provider/client/redirect/resource/scope policy behind a zero-arity loader so a client secret never appears in a supervisor child specification:

ok = application:set_env(
erlang_adk, auth_authorization_profile_loader,
{my_auth_profiles, load}).

my_auth_profiles:load/0 returns an immutable provider map. A production Oidcc entry has adapter_module => adk_oidcc_authorization_code_adapter and an adapter_context containing the configured provider_worker, client_id, and secret-manager-sourced client_secret; it also fixes the HTTPS redirect_uri, allowed_scopes, default_scopes, optional RFC 8707 resource, lifetime, and public prompt. The loader module is trusted operator code and must not use request or model data.

Authorization-URI construction defaults to a two-second deadline and code exchange to a 30-second deadline. Both adapter calls run in monitored, owner-bound workers with a 262,144-word default heap cap and bounded results; standalone adk_auth_sup deployments may tighten these through authorization_flow_opts (authorization_uri_timeout_ms, exchange_timeout_ms, and adapter_max_heap_words). The profile loader is trusted boot-time operator code, not a request callback or a hostile-code sandbox.

Begin the flow only from an authenticated application context. The returned map is directly usable with adk_suspension:request_credential/2:

{ok, CredentialRequest} = adk_authorization_flow:begin_flow(
adk_authorization_flow,
#{principal => Principal,
provider => <<"calendar">>,
scopes => [<<"openid">>, <<"calendar.read">>]}),
AuthorizationUri = maps:get(<<"authorization_uri">>, CredentialRequest),
State = maps:get(<<"correlation_id">>, CredentialRequest),
FlowRef = maps:get(<<"credential_flow_ref">>, CredentialRequest),
#{<<"pkce_method">> := <<"S256">>} = CredentialRequest.

The HTTPS callback gives the manager only the one-time state and bounded authorization code. It performs the exact provider/client/redirect/nonce/PKCE exchange internally and atomically replaces the pending credential:

{ok, ResumeResponse} = adk_authorization_flow:complete(
adk_authorization_flow, State, AuthorizationCode),
FlowRef = maps:get(<<"credential_ref">>, ResumeResponse),
State = maps:get(<<"correlation_id">>, ResumeResponse).

Provider profiles, nonce, verifier, client credentials, and token results remain in private/bounded processes and owner-private ETS. The high-entropy state is intentionally returned in the authorization URI and the bounded code arrives at the callback; both are transient correlation inputs and are never placed in model/session data or telemetry. State is atomically claimed before exchange, so replay, provider mix-up, callback races, expiry, timeout, and cancellation fail closed. The validated provider sub becomes the refresh credential's expected_subject; the separate issuer-bound local Principal remains the credential-store owner. Runner resume accepts only the matching opaque FlowRef and State, never a raw authorization code or token. Call adk_authorization_flow:cancel/2 when the browser abandons a flow.

The /dev bearer token is deliberately only local developer authentication. It must not be reused as end-user identity, a model API key, or a tool credential.

Phoenix LiveView companion

Phoenix is compatible with Erlang ADK and remains an optional companion rather than a core dependency. Version 0.7 checks in a complete Phoenix 1.8/LiveView application at examples/phoenix_adk_ui. It runs in the same BEAM release and calls adk_web_gateway directly; it does not proxy the local /dev/v1 administrator API.

The gateway authorizes requests in independent lightweight workers. Defaults admit 64 concurrent checks with a one-second callback deadline and a 100,000-word heap cap; deployments can tighten max_authorizations, authorizer_timeout_ms, and authorizer_max_heap_words in their trusted gateway_options/0. Callback crash, timeout, oversized input/result, caller death, or heap exhaustion fails closed without terminating the gateway.

The ordinary agent-run surface uses gemini-3.1-flash-lite and a server-owned agent catalog. The /live operations surface uses the separately configured ErlangAdkUi.LiveGateway; it discovers already-supervised Live sessions and never accepts a model, provider, transport, API key, principal, module, file path, or evaluation catalog from browser input.

For a loopback-only development session without an OIDC provider, enable the explicit local identity. It is accepted only in MIX_ENV=dev, forces the HTTP listener to 127.0.0.1, uses a fixed server-owned principal and scopes, and requires a CSRF-protected login POST. Production configuration rejects the flag:

cd examples/phoenix_adk_ui
export GEMINI_API_KEY="your_api_key_here"
export ADK_UI_LOCAL_AUTH=true
mix setup
mix phx.server

Open http://127.0.0.1:4000/auth/login and click Continue as local developer. No OIDC_* variables are required or read in this mode.

To exercise the production authentication contract during development, unset ADK_UI_LOCAL_AUTH and configure an OIDC client whose callback exactly matches the value below:

cd examples/phoenix_adk_ui
export GEMINI_API_KEY="your_api_key_here"
export OIDC_ISSUER="https://identity.example.com"
export OIDC_CLIENT_ID="erlang-adk-ui"
export OIDC_CLIENT_SECRET="load-this-from-your-secret-manager"
export OIDC_REDIRECT_URI="http://127.0.0.1:4000/auth/callback"
export OIDC_SCOPES="openid adk.agents.read adk.run.start adk.run.read adk.run.control adk.live.read adk.live.control adk.observability.read adk.evaluation.read"
mix setup
mix phx.server

Open http://127.0.0.1:4000/auth/login. A public OIDC client may set OIDC_PUBLIC_CLIENT=true instead of exporting a client secret; S256 PKCE remains mandatory. OIDC_SIGNING_ALGS defaults to RS256. The identity provider must actually grant the configured ADK scopes—changing a browser form or cookie cannot add them.

The browser cookie contains only Phoenix's encrypted session and opaque random handles. The nonce, verifier, provider exchange data, and sanitized identity remain in bounded, expiring server-side stores. The high-entropy OAuth state is also sent through the browser/IdP redirect and is atomically consumed before code exchange; it is not treated as a secret. Browser sessions rotate at login, logout revokes the server entry, and callback replay fails closed. The default production boundary uses external OIDC; the Erlang core deliberately does not store passwords. A deployment that needs local accounts may replace the auth provider with Phoenix-generated authentication and a maintained password hasher, while keeping the same issuer-bound identity and gateway policy contract.

Every connected mount and browser event re-fetches the server session and calls the default-deny Erlang gateway. Final output and replay-gap handling also re-check session validity and run ownership before rendering. The gateway:

A LiveView owns only a credit/ack subscription. The stable run is independently supervised, survives browser disconnects, and reconnects from the last acknowledged sequence stored in the server-side session. Rendered events, encoded bytes, prompts, outputs, login flows, sessions, and reconnect state are bounded. Confirmation and human-approval responses are typed; an unknown pause type remains paused and displays no action buttons.

The Live operations view uses a different future-only subscription contract. The Live session remains server-owned, but browser disconnect deliberately does not store a cursor or request replay. Each mount/action/event re-fetches the server-side OIDC session and requires exact scopes:

Audio/video bytes and thought signatures are removed before an event enters LiveView assigns; audio is shown there only as format/rate/channel/byte-count metadata. Full-duplex voice uses a separate same-origin, binary-only WebSocket and one ephemeral Erlang bridge process per connection. Browser microphone audio is resampled in an AudioWorklet to mono PCM s16le at 16 kHz; model PCM is scheduled through Web Audio with a bounded queue. Exact binary event ACKs hold the ADK credit window, and an interruption immediately purges scheduled audio. The socket re-fetches the opaque server session on every inbound, outbound, ping, and pong frame and on a bounded server-driven timer, rejects absent or cross-origin handshakes, and never exposes its bridge reference, principal, model, transport, or API key. Trusted discovery exposes a bounded voice_mode; controls appear only for an active automatic-VAD session, and the WebSocket rechecks both conditions to close the refresh/open race. Device-rate input passes through a streaming anti-alias resampler, worklet/main-thread, outbound socket, and playback queues are bounded, and a late microphone permission grant is stopped if its connection generation was cancelled. Final transcripts use a dedicated polite announcement region without making interim transcription updates noisy. Reports come from the trusted :erlang_adk_ui, :evaluation_reports release catalog as already decoded maps. The browser can select only an ID returned by that catalog; it cannot provide a filesystem path or evaluator module. The node-local reference gateway is suitable for a same-BEAM release. A clustered deployment needs an authenticated distributed session router rather than a client-selected node or PID.

Run the companion's deterministic and release gates from its directory:

mix format --check-formatted
mix compile --warnings-as-errors
mix assets.test
mix test
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release
mix hex.audit

The first six commands must pass. At the current lock, mix hex.audit intentionally exits non-zero for the two Cowlib 2.18.0 advisories documented in the companion README. That output is a visible release exception to resolve on an official fixed dependency, not a passing gate and not a reason to disable TLS verification or the audit. As of 2026-07-15, Cowlib 2.18.0 is still the latest official release and neither EEF advisory has a fixed-version event, so there is no safe official dependency bump yet.

The v0.6 companion baseline passed all 46 tests, production asset compilation, and release assembly. Its packaged release also booted with test-only trusted-proxy and direct-TLS configurations, returned HTTP 200 from /health on loopback in both modes, and stopped cleanly. The expanded v0.7 companion gate passes 101 ExUnit tests plus 31 dependency-free browser-audio tests with format, warnings-as-errors compilation, production assets, and release assembly. Its packaged release boots in both trusted-proxy and direct-TLS modes, returns HTTP 200 from /health on loopback with certificate verification enabled, and stops cleanly.

The lock file pins the official Phoenix LiveView fix commit for CVE-2026-58228 until a fixed Hex release at or above 1.2.7 is available. Do not replace that pin with an affected range merely to silence an audit. Keep mix.lock in version control.

For a direct-TLS release, export the OIDC values above with an HTTPS callback, then set SECRET_KEY_BASE, PHX_HOST, TLS_CERT_PATH, and TLS_KEY_PATH before starting the release:

export PHX_SERVER=true
export PHX_HOST="agents.example.com"
export PORT=8443
export PHX_URL_PORT=8443
export SECRET_KEY_BASE="$(mix phx.gen.secret)"
export OIDC_REDIRECT_URI="https://agents.example.com:8443/auth/callback"
export TLS_CERT_PATH="/run/secrets/tls-cert.pem"
export TLS_KEY_PATH="/run/secrets/tls-key.pem"
_build/prod/rel/erlang_adk_ui/bin/erlang_adk_ui start

Alternatively set PHX_BEHIND_HTTPS_PROXY=true and omit the certificate paths only when the HTTP listener is network-reachable exclusively through a trusted TLS terminator. Phoenix's forwarded-protocol rewrite is an explicit proxy trust decision: the proxy must overwrite X-Forwarded-Host, X-Forwarded-Port, and X-Forwarded-Proto, and forwarded headers from arbitrary clients are not a TLS boundary. Production configuration enforces secure/HttpOnly/SameSite cookies, connection-exact LiveView scheme/host/port, CSRF, CSP, HSTS, body limits, and a bounded channel implementation.

The production companion covers authenticated agent execution, typed decisions, future-only Live text/metadata, authenticated bounded browser voice, content-free operational snapshots, and read-only evaluation reports. It does not start/configure Live sessions, capture video, run evaluations from browser input, or mutate observability configuration. Artifact, memory, context, approver, operator, or admin panels require separate exact-scope server-side gateway operations; never give the browser a service PID, credential reference, filesystem root, cache lease, provider resource name, evaluator module/path, or caller-selected app/user scope. The Erlang /dev console remains loopback-only single-operator tooling and must not be mounted behind this public endpoint.

See the Phoenix companion guide for configuration, topology, testing, and release details.

Verification

Run the complete deterministic test and developer-tooling packaging gates with the repository's bundled Rebar3:

./rebar3 do clean, compile, eunit, ct, dialyzer
./scripts/coverage.sh
./rebar3 xref
./rebar3 escriptize
_build/default/bin/adk doctor
_build/default/bin/adk config validate examples/agent.json
./rebar3 ex_doc
./rebar3 hex build
./scripts/verify_hex_package.sh

For v0.7, adk doctor must report version 0.7.0, OTP 27, gemini-3.1-flash-lite as the REST default, all required dependencies, and a configured Gemini key without exposing it; checked validation must accept examples/agent.json with the same REST model. Live examples select gemini-3.1-flash-live-preview explicitly and do not change that default.

For historical comparison, the final v0.6 2026-07-14 clean run passed 899 EUnit tests, six deterministic Common Test cases, and warning-free Dialyzer over 170 project files. The v0.7 results below supersede that baseline.

The final v0.7 2026-07-16 clean Erlang gate completed 1,176 EUnit tests with no failures, six deterministic Common Test cases, and warning-free Dialyzer analysis over 210 project files. Aggregate deterministic Erlang line coverage is 73.88%, above the enforced 73% floor. Escript packaging, adk doctor, checked agent-config validation, and the focused README/runtime gates also pass.

Run the focused deterministic README smoke suite with:

./rebar3 eunit --module=readme_examples_test
./rebar3 eunit --module=readme_workflow_examples_test
erlc -Werror -pa _build/default/lib/erlang_adk/ebin -o /tmp \
examples/readme_weather_tool.erl \
examples/readme_live_weather_executor.erl \
examples/readme_stateful_counter_plugin.erl
./rebar3 eunit \
--module=adk_live_media_test,adk_live_gemini_codec_test,adk_live_gun_transport_test,adk_live_public_api_test,adk_live_session_test,adk_live_tool_execution_test,adk_live_observability_test,adk_live_voice_protocol_test,adk_live_voice_bridge_test,adk_plugin_pipeline_test,adk_plugin_runner_integration_test,adk_plugin_builtin_test,adk_plugin_stateful_test,adk_trace_context_test,adk_observability_v2_test,adk_observability_runner_test,adk_otlp_json_test,adk_otlp_http_json_exporter_test,adk_eval_criteria_test,adk_eval_v2_test,adk_eval_llm_judge_test,adk_eval_dev_view_test,adk_dev_v07_http_test,adk_cli_test
./rebar3 ct --suite test/runtime/invocations/adk_concurrency_stress_SUITE.erl
./rebar3 ct --suite test/integrations/stress/adk_v05_stress_SUITE.erl

The concurrency stress suite executes 1,000 stable runs in bounded concurrent batches across lightweight agent processes. It verifies every response against its exact session and invocation, unique run IDs, supervisor cleanup, and a stable test-process mailbox.

The 0.5 stress suite separately executes 1,000 bounded artifact/memory writes across isolated scopes and 128 concurrent context-cache acquisitions across four exact scopes; the latter collapse to four provider creates/deletes. The focused v0.6 run passed all 29 README examples, all four workflow examples, the 1,000-run concurrency scenario, and both v0.5 resource/context stress scenarios. On v0.7, the 29 README tests, four workflow tests, all three new example-module compile/runtime checks, and 193 focused Live/plugin/observability/evaluation/ developer-tooling tests pass. Both 1,000-run stress suites also pass in the final clean Common Test gate.

Run the opt-in billable REST provider suite after exporting GEMINI_API_KEY with:

ERLANG_ADK_GEMINI_REST=1 ./rebar3 ct \
--suite test/readme/readme_live_gemini_SUITE.erl

Despite its historical filename, readme_live_gemini_SUITE uses REST GenerateContent/SSE with gemini-3.1-flash-lite; it is not a Gemini Live WebSocket test. It exercises text generation, Google Search grounding metadata, thinking configuration, multimodal one-shot/content streaming, explicit context-cache creation and reuse, exact-scope model-selected memory and one-request artifact attachment, strict JSON Schema function declarations, function calling, SSE text streaming, correlated delegation, concurrent orchestration, sub-agents, Runner provider streaming, continuation-specific human approval, Mnesia Runner storage, callbacks, telemetry, evaluation, and the HTTP endpoint. It is skipped unless explicitly enabled because it uses network access, quota, and billable API calls.

The final v0.7 2026-07-15 REST run passed 15 of 17 cases with no skips. google_search_grounding and context_cache each received HTTP 429 after the single bounded ten-second retry. They are recorded as quota/rate-limit failures, not implementation passes; the exact-scope artifact/memory case and its strict parametersJsonSchema tool projection passed. The first-party bounded LLM rubric judge also passed against gemini-3.1-flash-lite.

Some scenarios require multiple model turns, so the complete REST suite makes roughly 39 Gemini API requests, including Search grounding, cached-content creation and reuse, model-selected memory/artifact tool rounds, and one-shot and SSE multimodal requests. By default, its test-only provider wrapper spaces request starts by 4.2 seconds, caps each transport wait at 15 seconds, and retries one non-streaming transport timeout. A non-streaming HTTP 429 receives one bounded retry after a test-only backoff of at least 10 seconds; a second 429 fails the case explicitly. The suite raises its agent call and direct-turn worker timeouts to 120 seconds; both production defaults remain 60 seconds. This pacing and retry policy do not change production request scheduling or the Erlang concurrency model. API limits are project-specific; accounts with a higher limit can shorten or disable the test pacing, for example:

ERLANG_ADK_GEMINI_REST=1 \
ERLANG_ADK_GEMINI_REST_INTERVAL_MS=0 \
./rebar3 ct --suite test/readme/readme_live_gemini_SUITE.erl

Keep the default interval on free-tier projects. Exhausted daily quota, project traffic outside this suite, or persistent account-level limits still fail explicitly instead of looping indefinitely.

ERLANG_ADK_LIVE_GEMINI=1 and ERLANG_ADK_LIVE_GEMINI_INTERVAL_MS remain accepted as historical aliases; new scripts should use the unambiguous ERLANG_ADK_GEMINI_REST names above.

The actual Gemini Live WebSocket gate is a separate opt-in paid suite using the explicit Live preview model:

ERLANG_ADK_GEMINI_LIVE=1 ./rebar3 ct \
--suite test/models/gemini/gemini_live_SUITE.erl

gemini_live_SUITE covers text-to-audio plus output transcription, 16 kHz PCM audio input, PNG image input, a correlated synchronous function-call round trip, and the owner-bound browser framing/ACK bridge against the real provider. It requires network/quota access and is skipped unless both the flag and GEMINI_API_KEY reach the Common Test process. Deterministic Live media/codec/session/transport/tool/observability tests remain the release contract even when this paid provider gate is skipped; a provider or quota failure is never counted as an implementation pass.

On 2026-07-15, the complete paid Live suite passed all five cases against gemini-3.1-flash-live-preview. This is provider-integration evidence, not a substitute for the clean deterministic release gate.

The focused suites compile the example modules and directly exercise deterministic versions of the core agent, delegation, legacy orchestration, supervised sequential/parallel/loop/transfer/graph workflows, explicit bounded planning, checkpoint resume, Runner provider streaming with exactly one final snapshot, session, callback, stream, MCP, evaluation, retry, memory, and artifact examples. HITL, Mnesia, authenticated developer startup, the project-specific HTTP endpoint, and Gemini HTTP/SSE wire behavior are covered by their dedicated EUnit modules in the complete suite above; the focused suites only check that those dedicated modules are present. A live Gemini quickstart still requires your own GEMINI_API_KEY, network access, quota, and may produce nondeterministic natural-language text.

The exact executable, compile-only, live, adapter-dependent, and conceptual classification for every README fence is tracked in README example coverage.

License

Erlang ADK is available under the Apache License 2.0.