Skip to content

Multi-agent scheduling and negotiation

When two or more agents need to agree on a time — a planner and two assistants, a scheduler and three reviewers — you need more than a bare event. You need a proposal: a set of candidate slots, a way for every participant to weigh in, and a deterministic rule for picking the winner.

Chronary’s scheduling proposal flow provides exactly that. An organizer agent offers 1–20 candidate slots to up to 50 participants, each participant accepts, declines, or counter-proposes, and Chronary resolves the proposal into a confirmed event on the winning slot.

  • Create a multi-slot proposal targeting multiple agents
  • Record each participant’s response (accept, decline, or counter)
  • Trigger resolution — or let it auto-resolve when everyone replies
  • Understand which proposal.* webhook fires at each step
  • Handle expiry, declines, and double-bookings gracefully
  • Organizer and participant agents already created (see the quickstart)
  • A target calendar where the resolved event will land

| State | Description | |-------|-------------| | pending | Initial state. Participants may respond. Organizer may resolve or cancel. | | confirmed | A winning slot has been picked and a calendar event created. Terminal. | | expired | expires_at passed without resolution — Chronary auto-transitioned the proposal. Terminal. | | cancelled | Explicitly cancelled by the organizer, or auto-cancelled because every participant declined. Terminal. |

Only pending → * transitions are valid. Responding, resolving, or cancelling a non-pending proposal returns 409 conflict.

sequenceDiagram
participant Org as Organizer agent
participant API as Chronary API
participant P1 as Participant 1
participant P2 as Participant 2
participant WH as Webhook subscribers
Org->>API: POST /v1/scheduling/proposals
API-->>WH: proposal.created
API-->>Org: 201 { id: spr_..., status: pending }
P1->>API: POST /v1/scheduling/proposals/:id/respond (accept slot A)
API-->>WH: proposal.responded
P2->>API: POST /v1/scheduling/proposals/:id/respond (accept slot A)
API-->>WH: proposal.responded
Note over API: All participants have now responded → auto-resolve
API->>API: pickBestSlot() + create event
API-->>WH: proposal.confirmed

If the organizer would rather resolve manually (for example, to pick a slot early before every participant has replied), they can call POST /v1/scheduling/proposals/:id/resolve at any time while the proposal is pending.

The organizer offers three candidate slots to two participant agents. weight is an optional organizer preference — higher means “I’d prefer this slot.” Default is 1.0.

Terminal window
curl -X POST https://api.chronary.ai/v1/scheduling/proposals \
-H "Authorization: Bearer chr_sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"title": "Q2 planning sync",
"description": "Review roadmap and lock the Q2 OKRs",
"organizer_agent_id": "agt_01H9X4organizer",
"participant_agent_ids": ["agt_01H9X4alice", "agt_01H9X4bob"],
"calendar_id": "cal_01H9X4team",
"slots": [
{ "start_time": "2026-04-20T14:00:00Z", "end_time": "2026-04-20T15:00:00Z", "weight": 2.0 },
{ "start_time": "2026-04-21T14:00:00Z", "end_time": "2026-04-21T15:00:00Z", "weight": 1.0 },
{ "start_time": "2026-04-22T16:00:00Z", "end_time": "2026-04-22T17:00:00Z", "weight": 1.5 }
],
"expires_at": "2026-04-19T00:00:00Z"
}'

Response:

{
"id": "spr_01H9X4proposal",
"title": "Q2 planning sync",
"status": "pending",
"organizer_agent_id": "agt_01H9X4organizer",
"participant_agent_ids": ["agt_01H9X4alice", "agt_01H9X4bob"],
"calendar_id": "cal_01H9X4team",
"expires_at": "2026-04-19T00:00:00Z",
"created_at": "2026-04-16T12:00:00Z",
"updated_at": "2026-04-16T12:00:00Z"
}

A proposal.created webhook fires immediately. If expires_at is set, Chronary also schedules a proposal.expired lifecycle fire for that time.

Responses reference a specific selected_slot_id. Fetch the full proposal to get the server-assigned slot IDs:

Terminal window
curl https://api.chronary.ai/v1/scheduling/proposals/spr_01H9X4proposal \
-H "Authorization: Bearer chr_sk_your_key_here"

Each participant agent sends its own response. A participant may respond at most once.

Terminal window
# Alice accepts the first slot
curl -X POST https://api.chronary.ai/v1/scheduling/proposals/spr_01H9X4proposal/respond \
-H "Authorization: Bearer chr_sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "agt_01H9X4alice",
"response": "accept",
"selected_slot_id": "slt_01H9X4slot1"
}'
# Bob also accepts the first slot
curl -X POST https://api.chronary.ai/v1/scheduling/proposals/spr_01H9X4proposal/respond \
-H "Authorization: Bearer chr_sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "agt_01H9X4bob",
"response": "accept",
"selected_slot_id": "slt_01H9X4slot1"
}'

Each call fires a proposal.responded webhook.

As soon as every participant has submitted a response, Chronary auto-resolves — the organizer does not need to call resolve explicitly. The server:

  1. Runs pickBestSlot(slots, responses) — an additive scorer where score = slot.weight + Σ(response_scores), with accept = 1.0, counter = 0.3, decline = 0.0. Ties break by the earliest start_time.
  2. If every response was a decline, marks the proposal cancelled with reason all_declined and fires proposal.cancelled.
  3. Otherwise creates a confirmed event on the winning slot’s calendar_id (falling back to the proposal’s calendar_id), updates the proposal to confirmed, and fires proposal.confirmed.

Behaviour: best-slot picker invoked when a proposal is resolved.

The organizer can force resolution at any time before everyone has replied — useful if some participants are offline and the remaining responses are enough to pick a winner.

Terminal window
curl -X POST https://api.chronary.ai/v1/scheduling/proposals/spr_01H9X4proposal/resolve \
-H "Authorization: Bearer chr_sk_your_key_here"

Response when a winner was picked:

{
"status": "confirmed",
"resolved_slot": {
"id": "slt_01H9X4slot1",
"start_time": "2026-04-20T14:00:00Z",
"end_time": "2026-04-20T15:00:00Z",
"weight": 2.0,
"calendar_id": "cal_01H9X4team"
}
}

Response when every response was a decline:

{ "status": "cancelled", "reason": "all_declined" }

A participant who can’t make any of the offered slots can respond with counter and include up to 20 alternative slots. The counter slots are informational — Chronary does not add them to the candidate set. They signal to the organizer: “none of your slots work; try one of these instead.”

Terminal window
curl -X POST https://api.chronary.ai/v1/scheduling/proposals/spr_01H9X4proposal/respond \
-H "Authorization: Bearer chr_sk_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"agent_id": "agt_01H9X4alice",
"response": "counter",
"counter_slots": [
{ "start_time": "2026-04-23T14:00:00Z", "end_time": "2026-04-23T15:00:00Z" },
{ "start_time": "2026-04-24T14:00:00Z", "end_time": "2026-04-24T15:00:00Z" }
],
"message": "Neither of the first two work for me — either of these would."
}'

Counter responses still participate in the scoring algorithm: a counter on a given slot contributes 0.3 to that slot’s score (vs. 1.0 for an accept). In practice this means pure counters rarely win on their own, but a counter paired with another participant’s accept can still carry a slot.

| Step | Webhook fired | Payload highlights | |------|---------------|--------------------| | Organizer creates proposal | proposal.created | Full proposal summary | | Any participant responds | proposal.responded | proposal_id, agent_id, response | | Resolution picks a winner | proposal.confirmed | proposal_id, resolved_slot, created_event_id | | Organizer cancels, or every participant declined | proposal.cancelled | proposal_id, reason (organizer_cancelled or all_declined) | | expires_at passes without resolution | proposal.expired | proposal_id |

Subscribe to any subset of these — a typical organizer subscribes to proposal.responded and proposal.confirmed; a typical participant only cares about proposal.created (to trigger its own response logic) and proposal.confirmed (to put the event on its schedule).

If expires_at passes before the proposal resolves, Chronary auto-transitions it to expired and fires proposal.expired. Any subsequent respond, resolve, or cancel call returns 409 conflict.

Expiry is scheduled in the lifecycle queue when the proposal is created. A 6-hour maintenance cron sweeps up proposals whose expires_at has just moved inside the queue retention window, so very long TTLs are handled in batches rather than at creation time.

If every response is decline, the resolver returns { status: 'cancelled', reason: 'all_declined' } and fires proposal.cancelled. No event is created.

Each (proposal_id, agent_id) pair may have at most one response. A second attempt returns 409 conflict with type: duplicate_response.

Responding after the proposal is no longer pending

Section titled “Responding after the proposal is no longer pending”

Any respond, resolve, or cancel on a confirmed, cancelled, or expired proposal returns 409 conflict.

Resolution creates an event via the normal event-creation path, which respects the calendar’s agent busy/free rules. If the winning slot would collide with an existing event on the same calendar, the event-creation call surfaces the conflict — handle it by cancelling the proposal and opening a new one with non-conflicting slots.

If the agent_id in a respond call is not in the proposal’s participant_agent_ids, the server returns 403 forbidden.

Free-tier orgs have a monthly proposal quota. Exceeding it returns 429 quota_exceeded on POST /v1/scheduling/proposals.