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 on purpose.
Not a scheduler
Titen provides only the state needed to prevent silent collisions. It does not pick which agent runs, retry anything, or hold a queue. Agent selection, scheduling, retries and the model loop stay with the caller, because a memory service that also schedules work ends up owning both and being good at neither.
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. It is not a mutex over the database; it is a fact other agents can check 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 — a partial index over unreleased rows
keeps that lookup cheap — and rejects when another principal holds one that has not yet
expired. The second principal gets 409 with the expiry, and nothing else:
{ "error": { "code": "CONFLICT",
"message": "Resource is leased by another principal until 2026-07-30T10:58:13.829Z." } }
That message is the whole conflict report. It names no holder, no purpose and no other resource, 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, which is the only moment it matters.
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, which is the point: this is an inbox, and it is 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. The sender cannot accept on the recipient’s behalf:
{ "error": { "code": "VALIDATION_ERROR",
"message": "Only the target principal can resolve a handoff." } }
Resolution is single-shot. 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.
What conflict handling actually means
Titen keeps five kinds of collision apart rather than calling all of them a race:
| 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 plane: 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 — an agent can take work and pass work along over MCP, and the
operator-shaped operations stay outside the agent profile. The SDK covers checkpoints but
not leases or handoffs; use fetch or MCP for those.