Documentation

Connecting the desk

The desk runs as a Cloudflare Worker and speaks MCP over streamable HTTP at https://desk.miranet.work/mcp. An OAuth 2.1 provider sits in front of that one route, so connecting is a browser login rather than a credential you paste. The Claude Code plugin bundles the connection with two skills that teach an agent how to use it well.

You talk to the desk; you do not operate it. Nothing here changes a ticket. You tell the desk what happened and it decides what that means for the backlog — the same thing a colleague in Slack does, and a colleague has no status tool either.

Install

The plugin lives in its own marketplace repo, Aroha-Labs/claude-plugins. Two commands inside Claude Code:

/plugin marketplace add Aroha-Labs/claude-plugins
/plugin install desk@arohalabs

The marketplace is named arohalabs, which is the @arohalabs suffix. The repo is private, so the first command clones it with your own git credentials — if you can git clone it, you can install from it. Later, /plugin marketplace update arohalabs pulls the current version.

The plugin is desk 0.8.0 and ships exactly two skills: desk-tools, which carries how to work with the desk (you talk, it decides; the cadence; reading actions; finishing well), and pick-work, which is choosing work and closing the loop. The parameters and limits come from the server's own instructions and tool text; a refusal code comes in the tool's refusal when it happens, with a sentence saying what to do. Run /desk:pick-work, or just ask what to work on — desk-tools describes itself as the thing to load whenever a desk tool is called.

Signing in

Nothing to set up. There is no key to mint and no environment variable to export: the plugin's .mcp.json carries a type and a URL and nothing else. On the first tool call Claude Code opens a browser, you sign in with the same email-code login the ops dashboard uses, and a consent screen asks whether to let the client act as you.

You need to be a desk admin. MCP is admin only since 2026-10-22. Both endpoints behind the consent screen are gated on a desk login and admin, and every MCP request checks again, so a person who stops being an admin is refused at once. A person whose desk profile has no display name is refused with 409 no_display_name, because everything the grant did would otherwise be recorded under a name that does not exist.

What the consent screen verifies is the origin, not the name. Claude Code identifies itself with a Client ID Metadata Document, so its client id is itself the URL https://claude.ai/oauth/claude-code-client-metadata; the desk fetches that document and requires its own client_id to equal the URL it came from. A name a client chose for itself is checked by nobody. Open client registration was removed on 2026-09-14, so a client that supports neither a metadata document nor a hand-registered id cannot connect at all — and the habit that goes with it is worth keeping: if a consent screen appears when you did not just start a sign-in, approve nothing.

Grants are listed and revoked from the ops dashboard's MCP keys page, under Sign-ins. An admin can revoke anybody's, so a lost laptop or a misbehaving agent can be stopped quickly.

Do not put a key in a header. A static bearer token does not reach /mcp: the provider checks the token before the route runs and refuses an external token that is not bound to its configured resource, which the desk's key resolver does not set. The plugin records measuring exactly that refusal on 2026-09-14. Worse, setting headers.Authorization in .mcp.json disables Claude Code's OAuth fallback — you then get a flat 401 with no login offered, which is how a leftover repo-level .mcp.json broke this connection once already.

Scopes

There are two, read and write. Claude Code asks for both; approving grants both, and there is no per-scope checkbox yet.

ScopeWhat it decides
readEvery tool on this surface. All eight require read and nothing more.
writeGates no tool you can call. It decides what the desk may do on your behalf.

That second row is the one worth reading twice. When a conversation you started wakes a desk turn, the desk's own write tools are bound for that turn only if your grant carries write — 13 of the 22 tools it would otherwise have are removed, leaving 8 reads plus its ability to reply to you. Connected third-party MCP tools are likewise narrowed to the ones their server marked read-only.

So with read alone the desk will answer you perfectly well and nothing on the backlog will move, whatever you ask for. If you say a ticket is finished and actions comes back empty, check the scope on your grant before concluding the desk disagreed.

A tool called without its scope returns insufficient_scope inside a normal result — isError: true with the code on structuredContent. Since every tool requires only read, in practice it means the credential lacks read.

The eight tools

Five look things up, two are the conversation, one reserves an upload. There is no ninth: the seven mutation tools this page used to list — claim_item, update_status, set_priority, merge_duplicate, post_internal_note, ask_reporter and link_work — were deleted on 2026-09-14. The capability moved rather than vanished: three of them exist as the desk's own tools, and you reach them by asking.

ToolArguments
search_itemsstatus?, kind?, product?, priority?, query?, limit? (1–200; 50 when omitted)
get_itemitemId — D-23, the old ND-23 (any case), or the uuid; required
get_signals— none
answerquestion
get_knowledgemode — "index" + products, "keys" + keys, or "query" + products + query
tell_desktext, files?
check_deskrequestId?
request_file_uploadmimetype, originalName?

search_items matches query as a case-insensitive substring against an item's title or its body — not full-text search, not fuzzy — and returns newest activity first. status, kind and product take one value each. Each row carries its id, display (the human number in the form D-16) and product; either the id or the number goes to get_item.

get_item returns the whole record, its product included: the event log, both message visibilities — what the reporter can see and what only the team can — and every linked file. When nothing is at the reference you gave, it returns { item: null } with the text no item at D-23. answer comes back in one of three shapes: from_record, from_knowledge, or not_known, which is a real answer rather than a failure. get_signals returns what the system flagged on its own, each with its subject.

Statuses: new, needs_info, open, in_progress, blocked, dev_complete, done, declined, duplicate. Only the last three end an item. dev_complete means finished and committed but not yet in users' hands, and nothing times out or sweeps an item sitting there. Kinds: unclassified, bug, issue, idea, question, task. Products: the names in the product list the server's instructions give when you connect (get_knowledge's description carries the same list), and unknown on an item nobody has placed yet. Priority is free text a person sets in the portal — there is no enum for it anywhere, the desk never sets it, and most items have none.

Talking to the desk

tell_desk is the whole write surface. You say what happened, in words, and get back a receipt — a requestId and a conversationId, never an answer. The reply arrives through check_desk.

tell_desk({ text: "D-44: fixed and committed in <sha>." })  → { requestId }
check_desk({ requestId })  → { status: "working" }
                           → { status: "done", reply, actions }

reply is what the desk said; actions is what it actually did. Read both — that is how you tell “closed it” from “decided not to”. A third outcome, failed, carries the reply and no actions key at all, because a turn that threw may have committed some tools before dying and nothing recorded which. It comes back as a success rather than a tool error, deliberately, so a caller does not retry a turn that already ran and may already have changed things.

Expect minutes, not seconds. A turn runs against a hard ceiling of 780,000 milliseconds — thirteen minutes — after which the answer is failed. When you get working, go do something else and ask again. check_desk({}) with no id collects anything the desk has said to you unprompted, such as a reporter answering. Each one is handed over exactly once — the call marks them read as it returns them — so keep what comes back; a result you discard is gone.

There is still no tool that links a commit to a ticket — the desk does that itself from what you say, so name the sha. And finished work normally lands at dev_complete rather than done: the release that ships the fix is what closes it, and you do not tell it which.

A credential with nobody behind it is refused. Both tell_desk, check_desk and request_file_upload return no_person for an identity that is a label rather than a person: there is nobody to hold the conversation with, and nobody for the desk to answer. Signing in with OAuth is what puts a person behind it.

Sending a file

request_file_upload hands back a name, a presigned uploadUrl good for fifteen minutes, and its expiresAt. PUT the bytes with exactly the mimetype you asked for — it is signed into the URL, so anything else is refused by the bucket.

There is no argument for an uploaded file on tell_desk. You mention the name inside your text — “df_7k3x9m2p8qvw.png shows the settings screen after the fix” — and the desk works out from the sentence around it what the file is evidence for, the way a person would. A name with nothing said about it is a file nobody can file. A log or document you can read yourself does not need an upload: send it as text in tell_desk's files. The name is also the resource id: file://<name> for the text, file://<name>/blob for the bytes.

tell_desk checks every name it finds in your text against the store before it writes anything, and refuses the whole message rather than half-filing it — nothing is recorded, so nothing is half-done. never_uploaded means you reserved a name and skipped the PUT; upload_window_closed means you skipped it for more than fifteen minutes; unknown_file is a typo; not_your_file is somebody else's name; file_too_large means the object went past the 25 MiB ceiling, which R2 cannot enforce at signing time, so it is measured afterwards and the object is deleted.

Eleven content types are accepted: image/png, image/jpeg, image/gif, image/webp, image/heic, application/pdf, text/plain, text/markdown, text/csv, text/html and application/json. Anything else is refused at the door with unsupported_type and the list in the message.

tell_desk also takes files directly, as { name, mimetype, text } — the file's contents as text, because you have the file and the desk cannot fetch it. The total across them is capped at 60,000 characters; past that the call is refused with files_too_large rather than silently losing the middle of a document.

Resources

The server exposes four MCP resources alongside the tools. All are read-scoped, and the check happens inside each read rather than at registration — so a credential without read is told it may not read them, instead of being shown a server that appears to have no knowledge.

ResourceWhat comes back
knowledge://<key>One curated entry as markdown. Enumerated, so resources/list is how you learn the keys.
file://<name>What the file says. For an image that is a generated description, and the answer says so.
file://<name>/blobThe bytes inline, up to 8 MiB; past that, a short-lived download link instead.
file://<name>/urlA presigned download link, good for five minutes.

An image's text is a vision model's reading of it, not the picture. The resource says as much and points at /blob, so a description cannot quietly become evidence.

Arguments the server refuses

search_items and get_item refuse an argument they do not know, instead of dropping it. The call comes back as a tool error (isError: true) whose text names the key:

search_items({"text": "transaction dates"})
→ MCP error -32602: Input validation error: Invalid arguments for tool search_items:
  [ { "code": "unrecognized_keys", "keys": [ "text" ], … } ]

The free-text filter is query. Until 2026-10-17 it was text, and a wrong name returned the newest 50 items, unfiltered, with no error.

get_knowledge has no call that returns everything

Every call names a mode, and an empty call is refused. Start with mode: "index" and products: [...] (names from the product list in the server's instructions): it lists key, title, area, product and bodyChars for each entry, with no bodies, plus a total per product. Then mode: "keys" with keys: ["the-one-you-chose"]. A key that names nothing comes back in missingKeys. mode: "query" with products and a query is a literal word match inside those products. A mode sent without its values, or with another mode's, fails with invalid_arguments and says which field.

What reaches a person

Nothing you call posts anywhere. tell_desk is the only tool that can cause a colleague to hear something, and only indirectly: it opens or appends to a conversation and wakes a desk turn, and the desk — if your grant carries write — may ask the reporter, tell the reporter, or reply in the thread a report came from. You never post under your own name, and the desk's messages go out under the desk's.

An internal note never leaves the building. There is one gate that decides what may be posted, it decides on the message's own visibility, and it refuses an internal one — in production, not only in a test.

The desk sends no phone pushes. A Slack reporter reads the desk in the thread they asked in. A reporter on a product's page (Grove HR's help desk, for example) reads it on that page. A status change alone does not reach a product's reporter; only what the desk says with ask_reporter or tell_reporter does.

Who the record says you are

An MCP caller writes nothing to the event log directly. What lands there is written by the desk, and it is attributed two different ways on purpose.

An item the desk files while talking to you names you. You are its reporter, its captured event is recorded as actor_kind: person with your desk person id, and it is marked as having arrived over MCP — the same as if you had filed it yourself.

Everything the desk decides is recorded as the desk. Closing something, moving a status, posting a note: actor_kind: ai, with a null actor id rather than a stand-in person, because the AI is not a person. The reason it gave is on the record beside it.

An OAuth grant carries your desk person id rather than a typed label, so your name is resolved when the record is read — rename yourself and it propagates through past history.

Transport

One JSON body per POST. The server is stateless: a fresh MCP server is built per request, there is no session id, and there is no SSE stream or resumability to rely on.

GET is refused. An authenticated GET /mcp answers 405 with allow: POST and a JSON-RPC error saying why: the server has no session to push a standalone stream to, and the spec asks a server in that position to say so rather than open a stream it can never write to. Unauthenticated, you get 401 instead — the OAuth provider refuses before the route is reached.