Leases & handoffs
The minimum state two agents need to avoid doing the same destructive work twice, and to pass an unfinished task to each other.
Not a scheduler
Titen provides only the state needed to prevent silent collisions. Agent selection, scheduling, retries and the model loop stay with the caller.
An orchestrator may take a post-commit event, choose a capable agent, and start it with a handoff id. The agent still has to accept the handoff, acquire the lease, and compile context under its own credential. Receiving an event never becomes membership.
Leases: expiring ownership of one work item
A lease names a resource and claims it for a bounded time. Other agents can check it before they start something destructive.
curl -X POST http://127.0.0.1:8787/v1/leases \
-H "authorization: Bearer $TITEN_API_KEY" -H 'content-type: application/json' \
-d '{"resource_type":"deploy","resource_id":"checkout-service",
"purpose":"run rollback smoke","ttl_seconds":600}'
{ "data": { "lease_id": "lease_b504df202cd34393959fe49bda97a062",
"expires_at": "2026-07-30T10:58:13.829Z" } }
ttl_seconds runs from 10 to 86 400. Acquire looks up the unreleased lease for that
organization, resource_type and resource_id, and rejects when another principal holds
one that has not yet expired. A partial index over unreleased rows keeps that lookup
cheap. The second principal gets 409 with the expiry:
{ "error": { "code": "CONFLICT",
"message": "Resource is leased by another principal until 2026-07-30T10:58:13.829Z." } }
The message names no holder and no purpose, so probing for leases leaks nothing beyond the fact that this one is taken.
There is no renew route. Acquiring again as the current holder, or after the TTL has
passed, releases the old row and inserts a new one, so you get a new lease_id and
the previous one immediately returns 404 on release. Keep the id from the most recent
acquire, or you will release nothing and think you did.
DELETE /v1/leases/:id releases, returns { "lease_id": "…", "released_at": "…" }, and
returns 404 on a lease that is already released or belongs to another organization.
Nothing sweeps expired leases in the background: the expiry check happens when the next
principal tries to acquire.
no fencing a lease does not block writes; it is advisory state your agents agree to check
no auto-release a crashed holder blocks the resource until its TTL expires, so keep TTLs short
no release event lease.acquired is emitted to the event stream; release is recorded on the row, not as an event
Handoffs: an explicit transfer
A handoff says who should continue, about which subject, and optionally with which context pack and checkpoint attached.
curl -X POST http://127.0.0.1:8787/v1/handoffs \
-H "authorization: Bearer $TITEN_API_KEY" -H 'content-type: application/json' \
-d '{"to_principal":"agent_reviewer","subject_id":"user_rama",
"checkpoint_id":"ckpt_713be19162544650a85b76b5ef6d7543",
"message":"Smoke passed, needs approval to promote."}'
# → {"data":{"handoff_id":"hoff_a690e57e15ac4ee2a24bf9c80ae5a991","status":"pending"}}
GET /v1/handoffs returns pending handoffs addressed to the caller. The sender’s own
list comes back empty: this is an inbox, and the pull path for agents that cannot receive
a webhook. Poll it, or subscribe to the handoff.created event if you run a dispatcher.
{ "data": { "handoffs": [ { "handoff_id": "hoff_a690e57e15ac4ee2a24bf9c80ae5a991",
"from_principal": "agent_8df7b8699a0945488ceb3d4b104006d6",
"to_principal": "agent_reviewer", "subject_id": "user_rama",
"context_id": null, "checkpoint_id": "ckpt_713be19162544650a85b76b5ef6d7543",
"message": "Smoke passed, needs approval to promote.",
"status": "pending", "created_at": "2026-07-30T10:48:13.859Z" } ] } }
POST /v1/handoffs/:id/resolve takes status of accepted or rejected, and only the
target principal may call it:
{ "error": { "code": "VALIDATION_ERROR",
"message": "Only the target principal can resolve a handoff." } }
A second resolve returns 404, because the lookup matches only status = 'pending', so a
retried request cannot flip an accepted handoff to rejected. Each transition emits
handoff.accepted or handoff.rejected to the event stream.
Conflict handling
Titen keeps five kinds of collision apart:
| Situation | Behaviour |
|---|---|
| Conflicting facts from different sources | the claim stays disputed until evidence or an authorized resolution exists |
| Different observer opinions | stay observer-scoped, never averaged |
| A decision replacing an earlier decision | explicit supersession |
| Concurrent checkpoint writes | today the upsert takes the last write; the state_hash tells you it changed. Compare-and-swap on an expected version is designed, not shipped |
| Duplicate work attempts | the lease returns 409 and reports the competing ownership |
No model is allowed to declare consensus on any of these.
From MCP
Two of the seven MCP tools cover this: titen_lease_acquire takes resource_type,
resource_id, purpose and ttl_seconds; titen_handoff takes to_principal,
subject_id and an optional message. Release, resolve and the pending list are
REST-only. The SDK covers checkpoints but not leases or handoffs; use fetch or MCP for
those.