Titentiten.devꦠꦶꦠꦺꦤ꧀
v0.1.1 · npmGitHub
DocsCollaborationLeases & handoffs

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.

lease TTL 10s – 24h409 on competing leasehandoff pull path

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.

handoffacceptleaseworkcheckpointrelease

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.

lease.shbash
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.

RE-ACQUIRE IS NOT RENEW

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.

WHAT A LEASE DOES NOT DO

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.

handoff.shbash
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.

Next

↑↓ navigate↵ open