Tasks & Messages

Tasks and messages are the core data model of the A2A protocol. Understanding their structure and lifecycle is essential for building agents.

Tasks

A Task represents a unit of work. Every SendMessage call creates one.

Task Structure

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::types::task::{ContextId, TaskId, TaskStatus, TaskState};
use a2a_protocol_sdk::types::message::{Message, MessageId, MessageRole, Part};
use a2a_protocol_sdk::types::artifact::{Artifact, ArtifactId};
pub struct Task {
    pub id: TaskId,                          // Server-assigned unique ID
    pub context_id: ContextId,               // Conversation thread ID
    pub status: TaskStatus,                  // Current state + optional message
    pub history: Option<Vec<Message>>,       // Previous messages in context
    pub artifacts: Option<Vec<Artifact>>,    // Produced results
    pub metadata: Option<serde_json::Value>, // Arbitrary key-value data
}
// The real type has exactly these fields: this literal of it names them all.
let _ = a2a_protocol_sdk::types::task::Task {
    id: TaskId::new("t"), context_id: ContextId::new("c"),
    status: TaskStatus::new(TaskState::Working), history: None, artifacts: None, metadata: None,
};
}

Task States

Tasks follow a state machine with validated transitions:

StateMeaningTerminal?
SubmittedReceived, not yet startedNo
WorkingActively processingNo
InputRequiredNeeds more input from clientNo
AuthRequiredNeeds authenticationNo
CompletedFinished successfullyYes
FailedFinished with errorYes
CanceledCanceled by clientYes
RejectedRejected before executionYes

Valid Transitions

Not all state transitions are allowed. The library enforces these rules:

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::TaskState;

// Check if a transition is valid
assert!(TaskState::Submitted.can_transition_to(TaskState::Working));
assert!(TaskState::Working.can_transition_to(TaskState::Completed));

// Terminal states cannot transition
assert!(!TaskState::Completed.can_transition_to(TaskState::Working));
assert!(!TaskState::Failed.can_transition_to(TaskState::Working));

// Check if a state is terminal
assert!(TaskState::Completed.is_terminal());
assert!(!TaskState::Working.is_terminal());
}

Terminal State Constraints

Tasks in terminal states enforce strict invariants:

  • No new messages — SendMessage to a terminal task returns UnsupportedOperation. Start a new task instead.
  • No subscription — SubscribeToTask on a terminal task returns UnsupportedOperation since no new events will be emitted.
  • No cancellation — CancelTask on a terminal task returns TaskNotCancelable.
  • Unknown taskId — SendMessage with a taskId that doesn't reference an existing task returns TaskNotFound.

Task Status

The status combines a state with an optional message and timestamp:

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::{TaskStatus, TaskState};

// Without timestamp
let status = TaskStatus::new(TaskState::Working);

// With automatic UTC timestamp
let status = TaskStatus::with_timestamp(TaskState::Completed);
}

Wire Format

On the wire, task states use SCREAMING_SNAKE_CASE with a TASK_STATE_ prefix:

{
  "id": "task-abc",
  "contextId": "ctx-123",
  "status": {
    "state": "TASK_STATE_COMPLETED",
    "timestamp": "2026-03-15T10:30:00Z"
  },
  "artifacts": [...]
}

Messages

A Message is a structured payload exchanged between client and agent:

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::types::task::{ContextId, TaskId, TaskStatus, TaskState};
use a2a_protocol_sdk::types::message::{MessageId, MessageRole, Part};
use a2a_protocol_sdk::types::artifact::{Artifact, ArtifactId};
pub struct Message {
    pub id: MessageId,                           // Unique message ID
    pub role: MessageRole,                       // User or Agent
    pub parts: Vec<Part>,                        // Content (≥1 part)
    pub task_id: Option<TaskId>,                 // Associated task
    pub context_id: Option<ContextId>,           // Conversation thread
    pub reference_task_ids: Option<Vec<TaskId>>, // Related tasks
    pub extensions: Option<Vec<String>>,         // Extension URIs
    pub metadata: Option<serde_json::Value>,
}
let _ = a2a_protocol_sdk::types::message::Message {
    id: MessageId::new("m"), role: MessageRole::User, parts: vec![], task_id: None,
    context_id: None, reference_task_ids: None, extensions: None, metadata: None,
};
}

Roles

RoleWire ValueMeaning
User"ROLE_USER"From the client/human side
Agent"ROLE_AGENT"From the agent/server side

Creating Messages

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::*;

let message = Message {
    id: MessageId::new(uuid::Uuid::new_v4().to_string()),
    role: MessageRole::User,
    parts: vec![Part::text("What is 2 + 2?")],
    task_id: None,
    context_id: None,
    reference_task_ids: None,
    extensions: None,
    metadata: None,
};
}

Parts

Parts are the content units within messages and artifacts. Four types are supported:

Text

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::*;
let part = Part::text("Hello, agent!");
assert_eq!(serde_json::to_value(&part).unwrap(), serde_json::json!({"text": "Hello, agent!"}));
}

Wire format: {"text": "Hello, agent!"}

Raw (inline bytes)

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::*;
let base64_encoded_string = "aGVsbG8=";
// Inline bytes (base64-encoded)
let part = Part::raw(base64_encoded_string);
}

Wire format: {"raw": "aGVsbG8=", "filename": "doc.bin", "mediaType": "application/octet-stream"}

Url (URI reference)

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::*;
// URI reference
let part = Part::url("https://example.com/document.pdf");
assert_eq!(serde_json::to_value(&part).unwrap(), serde_json::json!({"url": "https://example.com/document.pdf"}));
}

Wire format: {"url": "https://example.com/document.pdf"}

Structured Data

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::*;
let part = Part::data(serde_json::json!({
    "table": [
        {"name": "Alice", "score": 95},
        {"name": "Bob", "score": 87}
    ]
}));
assert!(serde_json::to_value(&part).unwrap()["data"]["table"].is_array());
}

Wire format: {"data": {"table": [...]}}

Part Metadata

Any part can carry optional metadata:

{
  "text": "Hello",
  "metadata": {"language": "en"}
}

Artifacts

Artifacts are results produced by an agent, delivered as part of a task:

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::types::task::{ContextId, TaskId, TaskStatus, TaskState};
use a2a_protocol_sdk::types::message::{Message, MessageId, MessageRole, Part};
use a2a_protocol_sdk::types::artifact::ArtifactId;
pub struct Artifact {
    pub id: ArtifactId,
    pub name: Option<String>,
    pub description: Option<String>,
    pub parts: Vec<Part>,                    // ≥1 part
    pub extensions: Option<Vec<String>>,
    pub metadata: Option<serde_json::Value>,
}
let _ = a2a_protocol_sdk::types::artifact::Artifact {
    id: ArtifactId::new("a"), name: None, description: None, parts: vec![],
    extensions: None, metadata: None,
};
}

Create and validate an artifact:

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::*;

let artifact = Artifact::new(
    "result-1",
    vec![Part::text("The answer is 42")],
);
// Validate before emitting — parts must be non-empty per A2A spec.
artifact.validate().expect("artifact should be valid");
}

Streaming Artifacts

Artifacts can be delivered incrementally during streaming:

#![allow(unused)]
fn main() {
use a2a_protocol_sdk::prelude::*;
async fn f(ctx: &RequestContext, queue: &dyn EventQueueWriter) -> A2aResult<()> {
// First chunk
queue.write(StreamResponse::ArtifactUpdate(TaskArtifactUpdateEvent {
    task_id: ctx.task_id.clone(),
    context_id: ContextId::new(ctx.context_id.clone()),
    artifact: Artifact::new("doc", vec![Part::text("First paragraph...")]),
    append: None,
    last_chunk: Some(false),  // More chunks coming
    metadata: None,
})).await?;

// Final chunk — parts are appended, metadata is merged key by key
queue.write(StreamResponse::ArtifactUpdate(TaskArtifactUpdateEvent {
    task_id: ctx.task_id.clone(),
    context_id: ContextId::new(ctx.context_id.clone()),
    artifact: Artifact::new("doc", vec![Part::text("Last paragraph.")]),
    append: Some(true),       // Append parts to existing artifact by ID
    last_chunk: Some(true),   // This is the last chunk
    metadata: None,
})).await?;
Ok(())
}
}

When append=true, the server finds the existing artifact by ID and:

  1. Appends the new parts to the existing parts list
  2. Merges metadata key by key at the top level (new keys override existing keys)

If no artifact with the ID exists, it is created as a new artifact.

ID Types

a2a-rust uses newtype wrappers for type safety:

TypeWrapsExample
TaskIdStringTaskId::new("task-abc")
ContextIdStringContextId::new("ctx-123")
MessageIdStringMessageId::new("msg-456")
ArtifactIdStringConstructed inside Artifact::new

These prevent accidentally passing a task ID where a context ID is expected.

Next Steps