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

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
  • No executor timeout

Full Configuration

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(AuthInterceptor::new())
    .with_interceptor(LoggingInterceptor::new())

    // 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)NoneTimeout for executor completion
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

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

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

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)TaskSubscription
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:

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:

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 tasks are evicted. When TTL expires, tasks are cleaned up on the next access.

Custom Task Stores

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

use a2a_protocol_sdk::server::TaskStore;

struct DynamoDbTaskStore { /* ... */ }

impl TaskStore for DynamoDbTaskStore {
    // Implement save, get, list, insert_if_absent, delete...
}

RequestHandlerBuilder::new(executor)
    .with_task_store(DynamoDbTaskStore::new(client))
    .build()

See Task & Config Stores for the full trait API.

Next Steps