Request Handler & Builder

The RequestHandler is the central orchestrator that connects your executor to the protocol. It manages task lifecycle, storage, streaming, push notifications, and interceptors. You build one using RequestHandlerBuilder.

Building a Handler

Minimal Setup

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::*;
struct MyExecutor;
agent_executor!(MyExecutor, |_ctx, _queue| async { Ok(()) });
use a2a_protocol_sdk::server::RequestHandlerBuilder;

let handler = RequestHandlerBuilder::new(MyExecutor)
    .build()
    .expect("build handler");
}

This gives you sensible defaults:

  • In-memory task store
  • In-memory push config store
  • No push sender (webhooks disabled)
  • No interceptors
  • No agent card
  • 1-hour executor timeout (without_executor_timeout() removes it)

Full Configuration

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::*;
struct MyExecutor;
agent_executor!(MyExecutor, |_ctx, _queue| async { Ok(()) });
use a2a_protocol_sdk::server::{HttpPushSender, TaskStoreConfig};
fn make_agent_card() -> AgentCard { AgentCard::new("my-agent", "1.0.0", AgentInterface::jsonrpc("http://localhost:3000")) }
use a2a_protocol_sdk::server::RequestHandlerBuilder;
use std::time::Duration;

let handler = RequestHandlerBuilder::new(MyExecutor)
    // Agent card for discovery
    .with_agent_card(make_agent_card())

    // Task storage
    .with_task_store_config(
        TaskStoreConfig::default()
            .with_task_ttl(Some(Duration::from_secs(3600))) // 1 hour TTL
            .with_max_capacity(Some(10_000)),               // Max 10k tasks
    )

    // Push notifications
    .with_push_sender(HttpPushSender::new())

    // Interceptors
    .with_interceptor(BearerTokenAuthInterceptor::new(["service-token"]))
    .with_interceptor(RateLimitInterceptor::new(RateLimitConfig::default()).expect("rate limit"))

    // Executor limits
    .with_executor_timeout(Duration::from_secs(300))

    // Streaming limits
    .with_event_queue_capacity(128)
    .with_max_event_size(8 * 1024 * 1024)    // 8 MiB
    .with_max_concurrent_streams(1000)

    .build()
    .expect("build handler");
}

Builder Methods Reference

Required

MethodDescription
new(executor)Set the agent executor (type-erased to Arc<dyn AgentExecutor>)

Optional

MethodDefaultDescription
with_agent_card(AgentCard)NoneDiscovery card for /.well-known/agent-card.json
with_task_store(impl TaskStore)InMemoryTaskStoreCustom task storage backend
with_task_store_config(TaskStoreConfig)1hr TTL, 10k capacityTTL and capacity for the default store
with_push_config_store(impl PushConfigStore)InMemoryPushConfigStoreCustom push config storage
with_push_sender(impl PushSender)NoneWebhook delivery implementation
with_interceptor(impl ServerInterceptor)Empty chainAdd a server interceptor
with_executor_timeout(Duration)1 hourTimeout for executor completion
without_executor_timeout()—Remove the 1-hour ceiling (an executor that never returns then pins its task, queue and cancellation token)
with_event_queue_capacity(usize)256Bounded channel size per stream
with_max_event_size(usize)16 MiBMaximum serialized event size
with_max_concurrent_streams(usize)1,024Limit concurrent SSE streams (pass usize::MAX to disable)
with_handler_limits(HandlerLimits)Sensible defaultsConfigurable limits (see HandlerLimits below)
with_task_store_arc(Arc<dyn TaskStore>)—Share a store instance via Arc
with_metrics(impl Metrics)NoopMetricsMetrics observer for handler activity
with_tenant_resolver(impl TenantResolver)NoneMulti-tenant tenant extraction
with_tenant_config(PerTenantConfig)NonePer-tenant concurrency, executor timeout and queue capacity. rate_limit_rps additionally needs the same config on RateLimitInterceptor::with_tenant_config
require_resolved_tenant()offRefuse a request the configured tenant resolver cannot place, instead of using the shared default partition
with_inbound_trace_policy(InboundTracePolicy)ContinueWhat to do with a traceparent sent by a not-yet-authenticated peer (Continue, Restart or Drop)
allow_unauthenticated_extended_card()offServe GetExtendedAgentCard even when no authenticating interceptor is registered (spec §13.3 requires one)
allow_undeclared_input_modes()off (enforced)When the card declares defaultInputModes or any skill's inputModes, SendMessage refuses a part whose explicit mediaType is outside them with ContentTypeNotSupportedError (-32005, HTTP 400). Parts without a mediaType, and cards declaring no modes, are not checked. This turns the check off

HandlerLimits

The HandlerLimits struct configures per-handler bounds:

FieldTypeDefaultDescription
max_id_lengthusize1024Maximum allowed length for task/context IDs
max_metadata_sizeusize1 MiBMaximum serialized metadata size in bytes
max_cancellation_tokensusize10,000Max cancellation token map entries before cleanup
max_token_ageDuration1 hourMaximum age for cancellation tokens
push_delivery_timeoutDuration5 secondsTimeout for individual push webhook deliveries
push_delivery_budgetDuration30 secondsTotal push-delivery time per event across every registered config (per request batch on the blocking path); the rest are counted skipped
executor_drain_timeoutDuration5 secondsHow long a blocking SendMessage waits for the event queue to close after the executor finished; then answers with the task as collected
max_artifacts_per_taskusize1000Maximum artifacts per task (prevents unbounded growth)
max_context_locksusize10,000Max per-context locks before cleanup
max_push_configs_per_taskusize100Maximum push configs per task (uniform across store backends)
max_parts_per_artifactusize10,000Maximum parts a single artifact may accumulate
max_total_push_configsusize100,000Global push-config ceiling across all tasks
subscribe_reattach_intervalDuration250 msHow often a SubscribeToTask stream re-checks whether its task has finished once the current turn's queue has closed
subscribe_max_idleDuration5 minutesHow long a SubscribeToTask stream waits for a parked task to make progress before ending; the client resubscribes (§3.5.2)
subscribe_replay_limitusize1,000Maximum logged events replayed to a client resuming with Last-Event-ID
subscribe_replay_catchupDuration2 secondsHow long a resuming SubscribeToTask waits for the event log to catch up with what was already broadcast; zero disables the wait

Build-Time Validation

build() validates:

  • If an agent card is provided, it must have at least one supported_interfaces entry
  • Executor timeout (if set) must not be zero
  • max_id_length must be greater than zero
  • max_metadata_size must be greater than zero
  • push_delivery_timeout must be non-zero
  • A signed agent card must already declare every extension the handler advertises (adding one would invalidate the signature)

Calling the Handler Directly

RequestHandler is the protocol layer, and it is transport-agnostic: no method takes an HTTP type. Requests arrive as parsed params plus a plain HashMap<String, String> of headers, which is all the interceptor chain needs to make access-control decisions. The dispatchers are one way to feed it, not the only way.

That matters if you already own your HTTP surface. An agent framework, an existing Axum or Actix application, a tower service, a queue consumer, or a test harness can call these methods directly and keep its own routing, middleware, and server lifecycle:

MethodA2A operation
on_send_message(params, streaming, headers)SendMessage, SendStreamingMessage
on_get_task(params, headers)GetTask
on_list_tasks(params, headers)ListTasks
on_cancel_task(params, headers)CancelTask
on_resubscribe(params, headers)SubscribeToTask
on_get_extended_agent_card(params, headers)GetExtendedAgentCard
on_set_push_config(params, headers)CreateTaskPushNotificationConfig
on_get_push_config(params, headers)GetTaskPushNotificationConfig
on_list_push_configs(params, headers)ListTaskPushNotificationConfigs
on_delete_push_config(params, headers)DeleteTaskPushNotificationConfig

Those ten methods are the whole protocol surface. Everything the dispatchers do on top is decoding the wire format and encoding the reply.

#![allow(unused)]
fn main() {
use std::collections::HashMap;

use a2a_protocol_server::agent_executor;
use a2a_protocol_server::builder::RequestHandlerBuilder;
use a2a_protocol_server::handler::SendMessageResult;
use a2a_protocol_types::params::MessageSendParams;
use a2a_protocol_types::{Message, MessageId, MessageRole, Part};

// Your agent. Nothing in it knows how the request arrived.
struct EchoAgent;
agent_executor!(EchoAgent, |_ctx, _queue| async { Ok(()) });

let handler = RequestHandlerBuilder::new(EchoAgent)
    .build()
    .expect("build handler");

// Whatever your framework hands you becomes params plus a header map.
let params = MessageSendParams::new(Message::user_text("msg-1", "hello"));

let mut headers = HashMap::new();
headers.insert("authorization".into(), "Bearer token".into());

let runtime = tokio::runtime::Runtime::new().expect("runtime");
let result = runtime
    .block_on(handler.on_send_message(params, false, Some(&headers)))
    .expect("send should succeed");

// `false` for `streaming` yields a synchronous response; `true` yields a
// reader you drain to produce SSE, a WebSocket feed, or whatever your
// transport emits.
assert!(matches!(result, SendMessageResult::Response(_)));
}

Pass None for headers when there is nothing to authenticate against — an in-process call, or a transport that has already authenticated the caller. Interceptors still run; they simply see an empty header set.

What you give up by skipping the dispatchers is exactly what they implement: JSON-RPC envelope parsing and error mapping, REST path and query binding, SSE framing, and the agent-card endpoint. What you keep is every protocol guarantee — task lifecycle, idempotency, streaming, push delivery, interceptors, multi-tenancy, and limits — because all of it lives here, below the transport.

Sharing the Handler

The handler is wrapped in Arc for sharing between dispatchers:

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::*;
struct MyExecutor;
agent_executor!(MyExecutor, |_ctx, _queue| async { Ok(()) });
use std::sync::Arc;

let handler = Arc::new(
    RequestHandlerBuilder::new(MyExecutor)
        .build()
        .unwrap()
);

// Both dispatchers share the same handler
let jsonrpc = JsonRpcDispatcher::new(Arc::clone(&handler));
let rest = RestDispatcher::new(handler);
}

This means JSON-RPC and REST clients share the same task store, push configs, and executor.

Task Store Configuration

The default InMemoryTaskStore supports TTL and capacity limits:

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::*;
struct MyExecutor;
agent_executor!(MyExecutor, |_ctx, _queue| async { Ok(()) });
fn f(executor: MyExecutor) -> ServerResult<RequestHandler> {
use a2a_protocol_sdk::server::TaskStoreConfig;
use std::time::Duration;

let config = TaskStoreConfig::default()
    .with_task_ttl(Some(Duration::from_secs(3600))) // Tasks expire after 1 hour
    .with_max_capacity(Some(50_000));               // Keep at most 50k tasks

RequestHandlerBuilder::new(executor)
    .with_task_store_config(config)
    .build()
}
}

When capacity is exceeded, the oldest terminal tasks are evicted first, then non-terminal ones if that is not enough. Terminal tasks older than the TTL are removed by a sweep that runs every eviction_interval writes (64 by default).

Custom Task Stores

For production use, implement the TaskStore trait for your database:

#![allow(unused)]
fn main() {
use std::future::Future;
use std::pin::Pin;
use a2a_protocol_sdk::prelude::*;
struct MyExecutor;
agent_executor!(MyExecutor, |_ctx, _queue| async { Ok(()) });
use a2a_protocol_sdk::types::task::TaskId;
use a2a_protocol_sdk::server::TaskStore;

struct MyTaskStore { /* ... */ }

impl TaskStore for MyTaskStore {
    // Implement save, get, list, insert_if_absent, delete...
    fn save<'a>(&'a self, _: &'a Task) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> { unimplemented!() }
    fn get<'a>(&'a self, _: &'a TaskId) -> Pin<Box<dyn Future<Output = A2aResult<Option<Task>>> + Send + 'a>> { unimplemented!() }
    fn list<'a>(&'a self, _: &'a ListTasksParams) -> Pin<Box<dyn Future<Output = A2aResult<TaskListResponse>> + Send + 'a>> { unimplemented!() }
    fn insert_if_absent<'a>(&'a self, _: &'a Task) -> Pin<Box<dyn Future<Output = A2aResult<bool>> + Send + 'a>> { unimplemented!() }
    fn delete<'a>(&'a self, _: &'a TaskId) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> { unimplemented!() }
}

fn f(executor: MyExecutor) -> ServerResult<RequestHandler> {
RequestHandlerBuilder::new(executor)
    .with_task_store(MyTaskStore { /* ... */ })
    .build()
}
}

See Task & Config Stores for the full trait API.

Next Steps