Dev.to AI πŸ€– Ai πŸ‘ 0 πŸ“– 9 min read

How does a Claude chat claim osFoundry tasks over MCP without push events?

>> {"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "claim_linked_chat_task", "arguments": {"chat_link_id": "3f6c0b1e-link", "task_id": "d97fd414-3916-4b13-937d-d78de6b3b053"}}} << {"jsonrpc": "

How does a Claude chat claim osFoundry tasks over MCP without push events?
>> {"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "claim_linked_chat_task",
    "arguments": {"chat_link_id": "3f6c0b1e-link", "task_id": "d97fd414-3916-4b13-937d-d78de6b3b053"}}}
<< {"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text",
    "text": "{\"claim_id\": \"8ffc774f-0a76-4846-9bbd-d66493ca08f4\", \"lease_until\": 1793942663.8222258}"}]}}

>> {"jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": {"name": "submit_linked_chat_result",
    "arguments": {"chat_link_id": "3f6c0b1e-link", "task_id": "d97fd414-…", "claim_id": "8ffc774f-…",
                  "status": "completed", "result": "1. Who signs off the pricing copy?"}}}
<< {"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text", "text": "{\"ok\": true}"}]}}

>> (same submit again, after a "lost" response)
<< {"jsonrpc": "2.0", "id": 7, "result": {"content": [{"type": "text", "text": "{\"ok\": true, \"replayed\": true}"}]}}

That is a claim, a submit, and a retried submit, captured from a Python rebuild (a 180-line sqlite server) of the hand-off osFoundry uses to send work to a linked Claude or ChatGPT conversation. The tool names are the real connector's. The server, the lease field and the replay flag are the rebuild's. No Claude binary or Claude account was used: the "chat" on the other side is a Python script making the same JSON-RPC calls a host would make, against a local sqlite file.

The shape of the problem is unusual. The worker is a chat conversation that only runs when a human opens it and types. The server can't wake it, can't see it, and can't tell whether the human closed the tab halfway through a task.

What does osFoundry actually expose?

Four tools on the osFoundry MCP connector carry the hand-off, and their schemas say most of what matters:

Tool Required arguments What its description promises
link_current_chat project_id, label, idempotency_key A separate link per conversation; label and reference are self-reported
read_linked_chat_tasks chat_link_id Queued and recent tasks, scoped to the OAuth connection and project
claim_linked_chat_task chat_link_id, task_id "Repeating the claim returns the same claim while the task remains active."
submit_linked_chat_result chat_link_id, task_id, claim_id, status, result completed or failed, result up to 20,000 characters; cancelled, expired, revoked or mismatched claims cannot be completed

The owner side lives in the web app, in apps/web/src/screens/settings/ConnectedChatTasks.tsx. Its status union is queued | claimed | completed | failed | cancelled | expired, labelled "Waiting for host chat" (or "Waiting for Claude"), "Claimed by host chat", "Completed", "Failed", "Cancelled" and "Expired". Assigning posts the goal with an idempotency_key from crypto.randomUUID(), and the component keeps that key while the goal text is unchanged, so a double-click or a retry after a timeout sends the same key. The panel refreshes its list with setInterval(..., 15000). Tasks expire after 24 hours.

A task assigned to the linked chat, waiting for Claude

Example: A task assigned to the linked chat, waiting for Claude

A fifth tool, upload_linked_chat_artifact, takes a stable artifact_id on retry. Same idea, applied to files.

Why can't the server push the task to Claude?

Because nothing on the host side is listening. osFoundry treats a Claude custom connector as request-driven: the active conversation can call tools, and there is no general event trigger that would start a turn. ChatGPT has MCP Events for Work chats that subscribe; the owner panel shows "An event subscription is active for this chat" only in that case, and otherwise "Open the host conversation to start each task."

So the scheduler is a person. That changes which failures are normal. A queued task sitting for six hours is not an outage. A claimed task whose chat was closed is the common case, not the edge case. And a retry is not only a network thing: the model may simply call the tool twice in one turn.

How do two claims on the same task both win?

The obvious handler reads the row, checks status == 'queued', and writes claimed. The rebuild has that version behind --naive 1, and the conditional version by default:

cur = c.execute("UPDATE tasks SET status='claimed', claim_id=?, lease_until=?, updated_at=? "
                "WHERE id=? AND chat_link_id=? AND status='queued'",
                (cid, now + self.lease, now, task_id, link))
if cur.rowcount == 1:
    return {"claim_id": cid, "lease_until": now + self.lease}

Test: 200 tasks, and for each one eight threads released by a threading.Barrier call claim_linked_chat_task at the same moment over HTTP.

Handler Trials with 2+ winners Mean winners per task
Read, then write (2 ms between them) 200 / 200 7.40
Read, then write (no added gap) 97 / 200 and 172 / 200 in two runs 1.82 and 3.36
Conditional UPDATE … WHERE status='queued' 0 / 200 1.00

The 2 ms gap stands in for a network hop or an ORM between the two statements. Without it the race still fires on roughly half or more of the trials, and the number moved with machine load. SQLite's write lock serialises the two writes but doesn't help: both readers already decided.

Eight simultaneous claims from one linked chat sounds contrived. Two is not: the user asks the chat to check tasks, gets impatient, asks again, and the host runs both turns. That is also why osFoundry's claim returns the existing claim to the same link instead of an error. The rebuild does the same for a repeat from the same chat:

"claim_replay": {"first": "28a03379", "repeat_same_chat": "28a03379", "same_claim": true,
                 "other_link": {"error": "not claimable: missing"}}

A different link asking for the same task ID gets nothing, not even confirmation that the task exists.

What should happen when the chat that claimed the task goes away?

This is where the rebuild goes further than anything the osFoundry docs state for linked chats. They state a 24-hour task expiry and, for the desktop dispatcher that lets an osFoundry agent wait on a linked chat, a 30-minute runtime limit. The rebuild adds a per-claim lease: a claim that isn't completed by lease_until goes back to queued and can be claimed again.

Expiry is lazy. There is no timer thread; every read, claim and submit first runs a sweep:

c.execute("UPDATE tasks SET status='queued', claim_id=NULL, lease_until=NULL, updated_at=? "
          "WHERE status='claimed' AND lease_until<=?", (now, now))

Test with a 1-second lease: chat A claims and goes silent. Chat B tries to claim every 100 ms.

"lease": {"lease_s": 1.0, "takeover_after_s": 1.064, "failed_claims_before_takeover": 10,
          "late_submit_A": {"error": "rejected: stale claim, task was re-claimed"},
          "submit_B": {"ok": true}}

B took over 64 ms after the lease ran out, which is one poll interval plus request time. The more important line is the third: when A came back and submitted with its old claim_id, the server refused it. The claim ID is a fencing token. Without it in the WHERE clause, A's late answer would overwrite B's, and the owner would see whichever finished last.

Choosing the lease length is the hard part with a human-driven worker. Too short and a slow but alive Claude turn loses its task mid-answer. Too long and a closed tab holds work for hours. A lease is only worth having if something else can pick the task up; with exactly one linked chat per task, the 24-hour expiry plus an owner "Cancel" button may be the honest design.

What happens when a submit response gets lost?

The host sends the result, the server commits, the response dies on the way back, the host retries. The test sends the same submit four times, then a fifth with a different result:

Handler Replies to 4 identical sends Reply to a changed result Stored result
Appends each call (like posting a message) ok, ok, ok, ok ok 4\n4\n4\n4\n5
Claim-fenced, result hash compared ok, then 3 Γ— ok + replayed rejected: task is completed 4

The idempotent version keeps sha256(status + result) on the row. A retry with the same claim and the same hash gets success; anything else against a terminal task is refused. osFoundry applies the same rule to completions on its Graph relay: an identical completion after a lost response is accepted again, a changed one is refused. The retry must return success, not an error. An error would push the model to "fix" a result that was already saved.

Can a cancelled task still be completed?

Not if cancel is a state change and submit requires status='claimed':

"cancel": {"cancel": {"cancelled": true}, "submit_after_cancel": {"error": "rejected: task is cancelled"}}

The interesting part is what the chat sees. The Claude turn did the work; the answer bounced. osFoundry's tool description tells the model in advance that cancelled claims can't be completed, which is the cheapest way to keep it from retrying in a loop.

What does polling cost?

Two different pollers exist. The owner's browser panel polls every 15 seconds, which is 5,760 requests a day per open panel. The host chat doesn't poll at all; it reads once each time you ask. Both hit the same read query.

Measured on the rebuild with 10,000 historic tasks across 500 links, 2,000 reads of one link, localhost HTTP:

Request Response Round trip p50 Server p50
Empty inbox 137 B 87 B 0.72 ms 0.26 ms
One queued task 137 B 294 B 0.89 ms 0.30 ms

Bandwidth is not the cost. The first version was about nine times slower on the server, and the reason was the lazy sweep: two UPDATEs on every read, which take SQLite's write lock even when they match nothing. Three runs of 2,000 empty polls each:

Sweep before each read Server p50 (3 runs)
Two UPDATEs, table scan 2.17 / 2.02 / 1.96 ms
Two UPDATEs, partial index on active rows 1.33 / 1.33 / 1.20 ms
SELECT 1 … LIMIT 1 on the partial index first, UPDATE only if something is due 0.24 / 0.21 / 0.22 ms
No sweep 0.19 / 0.19 / 0.22 ms

A read-only guard brings a poll within noise of no sweep at all. The index alone doesn't, because the write transaction is the cost, not the scan.

For a scripted client that does poll, the trade is the usual one. 40 tasks arriving 0.2 to 1.2 s apart:

Poll interval Mean pickup delay Max Polls Empty polls
0.1 s 0.058 s 0.107 s 244 204
0.5 s 0.226 s 0.484 s 54 17
2.0 s 1.103 s 2.006 s 15 2

Mean pickup lands near half the interval. For a host chat the real interval is "whenever the user opens it", and the per-read cost that matters is context, not milliseconds: every read result goes into the conversation. At 87 bytes for an empty inbox and about 210 more per queued task in this format, a terse task list keeps the check cheap enough to ask for freely.

All numbers: Apple M1, Python 3.9.6, SQLite 3.43.2 in WAL mode, ThreadingHTTPServer on 127.0.0.1, with other render jobs running on the machine (load average between 8 and 22 during the runs), so read the p50s and treat tails as noisy.

If you wire this up yourself

  • Make the claim a single conditional write and return the existing claim to the same caller; the double-click and the double tool call are the normal case.
  • Put the claim ID in the submit's WHERE clause so a late worker can't overwrite a newer one.
  • Store a hash of the terminal result and answer an identical retry with success, a changed one with a refusal.
  • Decide whether a lease buys anything. If only one chat can ever take the task, an expiry and a visible cancel may be all you need.
  • Keep expiry checks read-only until something is actually due; a poll that takes a write lock costs more than the query it guards.
  • Say in the tool description what will be refused, so the model doesn't retry against a cancelled task.
πŸ“° Read the original article on Dev.to AI

Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.