Component: Email request/response contract (issue #1262)
Module: gaia_agent_email.contract
Validation: pydantic v2
Schema version: 1.0
Overview
A frozen, stable request/response schema for the Email Triage Agent, shared
by the REST surface (#1229) and the
MCP stdio interface (#1104). GAIA owns
this contract — the consuming application conforms to it, not the other way
around. It is frozen here so the dependent endpoints can be built against a
stable shape.
Key properties:
- One schema, two surfaces. REST and MCP stdio import the same pydantic
models, guaranteeing identical structured output for a fixed input.
- Single email and full thread. The input is a discriminated union on a
kind field ("single" / "thread"); a consumer branches deterministically.
- Dependency-light.
gaia_agent_email.contract imports only pydantic — no
Gmail or connector backends — so either surface can import it without pulling
live-mail machinery into the process. (A regression test enforces this.)
- Fail loudly. Every model forbids unknown fields (
extra="forbid"). An
off-contract payload raises a ValidationError naming the offending field,
never a silently coerced result.
- Versioned.
SCHEMA_VERSION ("1.0") is pinned in the module and echoed in
both request and response so a consumer can detect a breaking change.
EmailTriageRequest — top-level envelope.
| Field | Type | Notes |
|---|
schema_version | string | Contract version. Defaults to "1.0". |
payload | SingleEmailInput | ThreadInput | Discriminated on kind. |
Both payload shapes carry a principal — the recipient the agent acts on
behalf of (the inbox owner). This is distinct from a message’s to: in a thread
the principal is not necessarily a recipient of every message.
EmailAddress:
| Field | Type | Notes |
|---|
name | string | null | Display name. Optional. |
email | string | Required. Rejected loudly if it lacks @ or a dotted domain. |
EmailMessage:
| Field | Type | Notes |
|---|
message_id | string | Provider message id. Required. |
thread_id | string | null | Owning thread id. |
from | EmailAddress | Sender. On the wire the key is from; in Python the field is from_ (keyword clash). Required. |
to | EmailAddress[] | Primary recipients. |
cc | EmailAddress[] | Carbon copies. |
bcc | EmailAddress[] | Blind carbon copies. |
date | string | null | ISO-8601 timestamp. |
subject | string | Subject line. |
body | string | Plain-text body to analyze. Required. |
| Field | Type | Notes |
|---|
kind | "single" | Discriminator. |
principal | EmailAddress | Inbox owner. Required. |
message | EmailMessage | The one message to analyze. Required. |
| Field | Type | Notes |
|---|
kind | "thread" | Discriminator. |
principal | EmailAddress | Inbox owner. Required. |
thread_id | string | Conversation id. Required. |
messages | EmailMessage[] | Non-empty, oldest-first. An empty thread is rejected loudly. |
Response schema (output)
EmailTriageResponse — top-level envelope.
| Field | Type | Notes |
|---|
schema_version | string | Echoes the contract version. |
request_kind | "single" | "thread" | Which input shape produced the result. |
result | EmailTriageResult | The structured analysis. |
EmailTriageResult:
| Field | Type | Notes |
|---|
category | enum | One of urgent, actionable, informational, low priority. Matches the agent’s frozen four-bucket taxonomy. |
is_spam | bool | Spam signal, scored independently of category. |
is_phishing | bool | Phishing signal, independent of is_spam. |
summary | string | Plain-text summary of the email / thread. Required. |
action_items | ActionItem[] | Extracted actions. May be empty. |
draft | DraftReply | null | Proposed reply, or null when none is suggested. Never sent without explicit confirmation (#1264). |
ActionItem:
| Field | Type | Notes |
|---|
description | string | Imperative action. Required, non-empty. |
due_hint | string | null | Free-text due hint as written ("Friday"); not parsed into a date. |
DraftReply:
| Field | Type | Notes |
|---|
to | EmailAddress[] | Non-empty proposed recipients. |
subject | string | Proposed subject. |
body | string | Proposed body. |
Example — single email
Request
{
"schema_version": "1.0",
"payload": {
"kind": "single",
"principal": { "name": "Alice Example", "email": "[email protected]" },
"message": {
"message_id": "msg-1",
"thread_id": "thread-1",
"from": { "name": "Bob Sender", "email": "[email protected]" },
"to": [{ "name": "Alice Example", "email": "[email protected]" }],
"cc": [],
"date": "2026-05-30T09:00:00Z",
"subject": "Q2 invoice attached",
"body": "Hi Alice, please review the attached invoice by Friday."
}
}
}
Response
{
"schema_version": "1.0",
"request_kind": "single",
"result": {
"category": "actionable",
"is_spam": false,
"is_phishing": false,
"summary": "Vendor invoice needs review by Friday.",
"action_items": [
{ "description": "Review the Q2 invoice", "due_hint": "Friday" }
],
"draft": {
"to": [{ "name": "Bob Sender", "email": "[email protected]" }],
"subject": "Re: Q2 invoice attached",
"body": "Thanks Bob, I'll review and confirm by Friday."
}
}
}
Example — full thread
Request
{
"schema_version": "1.0",
"payload": {
"kind": "thread",
"principal": { "name": "Alice Example", "email": "[email protected]" },
"thread_id": "thread-42",
"messages": [
{
"message_id": "msg-1",
"thread_id": "thread-42",
"from": { "name": "Bob", "email": "[email protected]" },
"to": [{ "name": "Alice", "email": "[email protected]" }],
"date": "2026-05-30T09:00:00Z",
"subject": "Contract renewal",
"body": "Can we hop on a call about the renewal?"
},
{
"message_id": "msg-2",
"thread_id": "thread-42",
"from": { "name": "Alice", "email": "[email protected]" },
"to": [{ "name": "Bob", "email": "[email protected]" }],
"date": "2026-05-30T10:00:00Z",
"subject": "Re: Contract renewal",
"body": "Sure, does Thursday 2pm work?"
}
]
}
}
Response
{
"schema_version": "1.0",
"request_kind": "thread",
"result": {
"category": "actionable",
"is_spam": false,
"is_phishing": false,
"summary": "Bob wants a renewal call; Alice proposed Thursday 2pm.",
"action_items": [{ "description": "Confirm Thursday 2pm call" }],
"draft": null
}
}
Usage
Validate a payload at a boundary (REST endpoint, MCP tool handler). Both helpers
raise loudly on a contract violation — never return a partial object:
from gaia_agent_email.contract import parse_request, parse_response
request = parse_request(raw_request_dict) # -> EmailTriageRequest
if request.payload.kind == "thread":
for message in request.payload.messages:
...
response = parse_response(raw_response_dict) # -> EmailTriageResponse
Stability contract
- Frozen at
1.0. Additive, backward-compatible changes (new optional
fields) keep the version. Any breaking change (renamed/removed field, new
required field, taxonomy change) bumps SCHEMA_VERSION so consumers detect it.
- Categories never drift. The four-bucket taxonomy is mirrored from the
agent’s
triage_heuristics.ALL_CATEGORIES; a unit test asserts byte-for-byte
equality, so a taxonomy change in either place fails CI.
- Unknown fields are errors, not warnings — there is no silent forward-compat
drift in either direction.