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.
What you’ll accomplish
Section titled “What you’ll accomplish”- 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
Prerequisites
Section titled “Prerequisites”- Organizer and participant agents already created (see the quickstart)
- A target calendar where the resolved event will land
State machine
Section titled “State machine”| 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.
Sequence diagram
Section titled “Sequence diagram”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.confirmedIf 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.
Walk-through
Section titled “Walk-through”1. Organizer creates a proposal
Section titled “1. Organizer creates a proposal”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.
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" }'import Chronary from '@chronary/sdk';
const client = new Chronary();
const proposal = await client.scheduling.create({ 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',});console.log(proposal.id); // 'spr_01H9X4...'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.
2. Fetch slot IDs
Section titled “2. Fetch slot IDs”Responses reference a specific selected_slot_id. Fetch the full proposal to get the server-assigned slot IDs:
curl https://api.chronary.ai/v1/scheduling/proposals/spr_01H9X4proposal \ -H "Authorization: Bearer chr_sk_your_key_here"const full = await client.scheduling.get('spr_01H9X4proposal');const firstSlotId = full.slots[0].id; // e.g. 'slt_01H9X4slot1'3. Each participant responds
Section titled “3. Each participant responds”Each participant agent sends its own response. A participant may respond at most once.
# Alice accepts the first slotcurl -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 slotcurl -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.
4. Auto-resolve on the last response
Section titled “4. Auto-resolve on the last response”As soon as every participant has submitted a response, Chronary auto-resolves — the organizer does not need to call resolve explicitly. The server:
- Runs
pickBestSlot(slots, responses)— an additive scorer wherescore = slot.weight + Σ(response_scores), withaccept = 1.0,counter = 0.3,decline = 0.0. Ties break by the earlieststart_time. - If every response was a decline, marks the proposal
cancelledwith reasonall_declinedand firesproposal.cancelled. - Otherwise creates a confirmed event on the winning slot’s
calendar_id(falling back to the proposal’scalendar_id), updates the proposal toconfirmed, and firesproposal.confirmed.
Behaviour: best-slot picker invoked when a proposal is resolved.
5. (Optional) Organizer resolves early
Section titled “5. (Optional) Organizer resolves early”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.
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" }Counter-proposals
Section titled “Counter-proposals”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.”
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.
Webhook events at each step
Section titled “Webhook events at each step”| 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).
Error cases
Section titled “Error cases”Expired proposals
Section titled “Expired proposals”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.
All participants declined
Section titled “All participants declined”If every response is decline, the resolver returns { status: 'cancelled', reason: 'all_declined' } and fires proposal.cancelled. No event is created.
Duplicate response from the same agent
Section titled “Duplicate response from the same agent”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.
Double-booking protection
Section titled “Double-booking protection”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.
Agent not a participant
Section titled “Agent not a participant”If the agent_id in a respond call is not in the proposal’s participant_agent_ids, the server returns 403 forbidden.
Quota exceeded
Section titled “Quota exceeded”Free-tier orgs have a monthly proposal quota. Exceeding it returns 429 quota_exceeded on POST /v1/scheduling/proposals.
What’s next?
Section titled “What’s next?”- Scheduling API reference — full request/response shapes for every endpoint
- Webhooks guide — signature verification and retry behavior for
proposal.*events - Calendar-driven agent orchestration — react to the event that lands on the calendar once a proposal resolves