For agents
This page is addressed to a model reading this raw file, unrendered — the machine path, with no human detour. Every example below was replayed against a real PalmaPlan server (not copied from a specification); the identifiers and tokens are those of that trial, to be replaced with your own.
1. Discovering the server
A call to /mcp with no token says where to go:
POST /mcp
Content-Type: application/json
Accept: application/json, text/event-stream
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="http://<host>/.well-known/oauth-protected-resource"
{"type":"https://palmaplan.dev/errors/UNAUTHORIZED","title":"/mcp only accepts a Bearer: a named token from Settings › Connections and agents, missing, invalid or revoked","status":401,"code":"UNAUTHORIZED"}
GET the address carried by resource_metadata gives the resource and its issuer:
{
"resource": "http://<host>/mcp",
"authorization_servers": ["http://<host>"],
"scopes_supported": ["read", "write"],
"bearer_methods_supported": ["header"]
}
GET /.well-known/oauth-authorization-server on that issuer gives the OAuth endpoints (authorization_endpoint, token_endpoint, registration_endpoint, revocation_endpoint) — useful if you follow path 2b below.
2. Getting a Bearer
Two paths, depending on whether a human is in the loop.
2a. A token already given (the shortest path). A human creates a named token from Settings › Connections and agents › Advanced access (scope read or write) and hands it to you — as an environment variable, for example. No OAuth round trip is needed: this token is used directly as Authorization: Bearer pp_… on /mcp. This is the path stdio takes (Claude Desktop), and it remains available over HTTP.
2b. Dynamic registration + OAuth 2.1 (the autonomous path). You register yourself (POST /oauth/register, RFC 7591: client_name, redirect_uris), then build an authorization address with mandatory PKCE S256 and resource equal to <host>/mcp (RFC 8707). A human must open this address and click "Authorize" — this step doesn't automate, by design: it's the consent of the person who owns the data. See Connecting Claude Code for the full, exactly-replayed sequence. The code obtained is exchanged for a named token (POST /oauth/token).
In both cases, the result is the same: a pp_… token, with read and/or write scopes (detail).
3. Opening an MCP session
initialize, with the Bearer:
POST /mcp
Content-Type: application/json
Accept: application/json, text/event-stream
Authorization: Bearer pp_…
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0"}}}
The response is in SSE format (Content-Type: text/event-stream), even for a single message — expect event: message\ndata: {…}\n\n, not raw JSON. It also carries the Mcp-Session-Id header: keep it and send it back on every following call.
200 OK
Mcp-Session-Id: 01a0dd94-a733-790d-bb2c-0cc7dad419b3
Content-Type: text/event-stream
event: message
data: {"result":{"protocolVersion":"2025-11-25","capabilities":{"resources":{"listChanged":true,"subscribe":true},"tools":{"listChanged":true},"prompts":{"listChanged":true}},"serverInfo":{"name":"palmaplan","version":"0.33.1"}},"jsonrpc":"2.0","id":1}
serverInfo.version is the version of the instance answering you: a different number is not an error. This capture was taken against a server running 0.15.0; that field is the only one a release changes, and it is retouched at every closure rather than leaving the page announce a stale version — at every closure since 0.20.0; it reads 0.33.1 here today. The rest of the capture is untouched. Since 0.21.0 a test enforces it (scripts/site-versions.test.ts): the numbers guard sweeps docs/ too.
Since 0.33.0, the initialize response also carries instructions: a short text that says where to start (how_to, then list_spaces and list_schedules), the rules that avoid costly mistakes, and the session's tool profile. how_to is the signpost: with no argument, the list of recipes; with intention (a key from that list, or the intent in a few words), the recipe — the tools in order, an example call, the pitfalls and how to check. /mcp?profil=essentiel opens a session that lists only part of the tools; an unknown profile is refused (400, named).
4. Building a schedule
tools/call, with the same Bearer and the Mcp-Session-Id received in step 3:
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"create_schedule","arguments":{"name":"Agent showcase"}}}
{"result":{"content":[{"type":"text","text":"Schedule \"Agent showcase\" created.\nid: 01a0dd9c-…"}],"structuredContent":{"schedule":"01a0dd9c-…","seq":0}},"jsonrpc":"2.0","id":2}
A fresh schedule is born with a "Phase 1" group and three empty rows — usable directly by its label, with no need to re-read the schedule to find its identifier: anywhere PalmaPlan expects an identifier, a name works. To place a row and a block:
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"add_rows","arguments":{"schedule":"Agent showcase","group":"Phase 1","rows":[{"periods":[{"start":"2026-10-05","duration":5,"label":"Scoping"}]}]}}}
{"result":{"content":[{"type":"text","text":"1 row added, 1 block.\nids: 01a0dd9c-…"}],"structuredContent":{"dry_run":false,"seq":2,"applied":[…],"effects":[…],"warnings":[]}},"jsonrpc":"2.0","id":3}
For a whole schedule in a single call rather than row by row, see build_schedule in the generated reference — the tool the plan_project prompt recommends.
5. Reading a schedule
By a tool — the full view, in readable Markdown:
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_schedule","arguments":{"schedule":"Agent showcase"}}}
Or by a resource — the same data, addressable with no tool call:
{"jsonrpc":"2.0","id":5,"method":"resources/read","params":{"uri":"palmaplan://schedule/01a0dd9c-…"}}
palmaplan://schedule/{id}/json renders the same thing as normalized JSON, for exact processing rather than reading. palmaplan://schedules (with no identifier) lists every schedule accessible to this token.
6. Absences and risks (S22)
An absence belongs to a person, not a schedule: posted once, it holds across every schedule that person is a member of, and it never moves a block — it flags a risk. The reason is one of conges, temps_partiel, formation, autre (congés, part-time, training, other): never a medical reason, never a free-text field — the schema refuses both (health data is a special category under GDPR article 9, and a scheduling tool only needs to know that you're out, never why).
First, link a row to the person who works it — set_row_member accepts "me", a name, an email, or an id (list_members gives those):
{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"set_row_member","arguments":{"schedule":"Refonte","row":"2.1","member":"anne@x.test"}}}
{"result":{"content":[{"type":"text","text":"ligne 2.1 reliée à Anne.\nrow: 01a0f9e5-…"}],"structuredContent":{"dry_run":false,"seq":4,"applied":[…],"row":"01a0f9e5-…","member_id":"01a0f9e5-…"}},"jsonrpc":"2.0","id":6}
A schedule's members' absences are read with list_absences — the same visibility as the screen's Agenda, through the same route (GET /schedules/:id/absences): a stranger or a former member sees nothing there, even one a row or an action still names:
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"list_absences","arguments":{"schedule":"Refonte"}}}
{"result":{"content":[{"type":"text","text":"Absences des membres de ce planning :\nAnne :\n - Congés — le 2026-08-19\n id: 01a0f9e5-…"}],"structuredContent":{"schedule_id":"01a0f9e5-…","members":[{"user_id":"01a0f9e5-…","name":"Xavier","role":"owner"},{"user_id":"01a0f9e5-…","name":"Anne","role":"editor"}],"absences":[{"id":"01a0f9e5-…","user_id":"01a0f9e5-…","motif":"conges","forme":{"kind":"periode","date_from":"2026-08-19","date_to":"2026-08-19","debut":"matin","fin":"apres_midi"},"created_at":"2026-07-01T00:00:00.000Z","updated_at":"2026-07-01T00:00:00.000Z"}],"linked":[{"user_id":"01a0f9e5-…","current":true,"rows":["01a0f9e5-…"],"actions":[]}]}},"jsonrpc":"2.0","id":7}
Without schedule, list_absences returns your own (/me/absences); add_absence posts one — a period (a day, a half-day, or a long stretch) or recurring days (every Friday, every other Friday…) — and remove_absence takes one off, by its id.
Finally, get_schedule_risks says which blocks are at risk — the same calculation, the same list as the Risks screen (risquesDAbsence, in the core, which knows neither accounts nor the database):
{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"get_schedule_risks","arguments":{"schedule":"Refonte"}}}
{"result":{"content":[{"type":"text","text":"1 risque :\n- « Tests » — Anne — 1 j ouvré sur 5 (partiel), du 2026-08-19 au 2026-08-19 (ligne reliée)\n period: 01a0f9e5-…"}],"structuredContent":{"schedule":"01a0f9e5-…","risks":[{"period_id":"01a0f9e5-…","user_id":"01a0f9e5-…","source":"ligne","jours_ouvres_absents":1,"jours_ouvres_total":5,"partiel":true,"premier_jour":"2026-08-19","dernier_jour":"2026-08-19"}],"lignes_membre_retire":[]}},"jsonrpc":"2.0","id":8}
A block is at risk as soon as at least one working day of its span falls on the absence of someone who works it — through the linked row, or through an action assigned to them (add_action { assignee }). jours_ouvres_absents counts in half-days (0.5 for an absence covering only one half of a day); partiel is only false when the absence covers the whole block. A row linked to someone who is no longer a member of the schedule shows up separately, in lignes_membre_retire, with no date at all — a stale link never leaks an absence.
7. The kanban: what's waiting for me (S23)
list_my_actions returns what's assigned to you — across every schedule —, grouped by schedule: no need to know a schedule id to find out what's waiting for you on a Monday morning.
{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"list_my_actions","arguments":{}}}
{"result":{"content":[{"type":"text","text":"# Mes actions\n\n1 action sur 1 planning :\n\n## Suivi agents\n- ○ Rediger le plan — Cadrage (01a0fb2a-…)\n\nas_of: 2026-10-02T05:51:20.153Z"}],"structuredContent":{"as_of":"2026-10-02T05:51:20.153Z","schedules":[{"id":"01a0fb2a-…","name":"Suivi agents","actions":[{"action":{"id":"01a0fb2a-…","period_id":"01a0fb2a-…","title":"Rediger le plan","status":"todo","assignee":{"kind":"user","user_id":"01a0fb2a-…"}},"period_label":"Cadrage","change":null}]}],"removed":[],"since_unknown":[]}},"jsonrpc":"2.0","id":9}
as_of is the "since my last visit" loop: pass it back as since on the next call. With since, the same response also carries, per action, change ("new", "updated", or null), and a removed array — every action that used to be yours and no longer is, with its reason spelled out (reassigned, unassigned, deleted, access_lost), never a silent disappearance.
An action's history — who, when, and the time per column, read from the existing journal, no new storage:
{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"get_action_history","arguments":{"schedule":"Suivi agents","action":"Rediger le plan"}}}
{"result":{"content":[{"type":"text","text":"# Histoire de « Rediger le plan »\n\n- 2026-10-02 05:51 · Xavier · créée\n- 2026-10-02 05:51 · Xavier · passée de à faire à en cours\n\nDurée par colonne : < 1 h à faire, < 1 h en cours, 0 h fait.\nActuellement en cours depuis le 2026-10-02 05:51."}],"structuredContent":{"action_id":"01a0fb2a-…","events":[{"kind":"created","who":{"name":"Xavier"}},{"kind":"status","from":"todo","to":"doing","who":{"name":"Xavier"}}],"columns":{"todo_ms":20,"doing_ms":11,"done_ms":0,"current":"doing","since":"2026-10-02T05:51:20.160Z"}}},"jsonrpc":"2.0","id":10}
update_action moves an action to another block with period — the progress of both blocks (the one it leaves, the one it joins) follows, derived from their actions:
{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"update_action","arguments":{"schedule":"Suivi agents","action":"Rediger le plan","period":"Tests"}}}
{"result":{"content":[{"type":"text","text":"Action « Rediger le plan » modifiée — responsable : vous : « Tests » à 0 % (0/1) ; « Cadrage » n a plus d action : son avancement (0 %) se règle à nouveau à la main.\nid: 01a0fb2a-…"}],"structuredContent":{"applied":[{"type":"action.move","payload":{"id":"01a0fb2a-…","period_id":"01a0fb2a-…"}}],"progress":0}},"jsonrpc":"2.0","id":11}
get_action_alerts says what's stuck: an unfinished action whose block is over (en_retard), an "in progress" action that hasn't moved in idle_days days (immobile, seven by default), an action with no owner in a block that has already started (sans_responsable), or one assigned to someone who's out during its block (absent) — an action can carry more than one alert at once. Without schedule, all of yours across every schedule you can read; with one, a single schedule's:
{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"get_action_alerts","arguments":{"schedule":"Suivi agents"}}}
{"result":{"content":[{"type":"text","text":"# Ce qui coince\n\n2 alertes :\n- ⏰ « Sans responsable » (Cadrage) — en retard de 4 j ouvrés (bloc terminé le 2026-09-28)\n- ❓ « Sans responsable » (Cadrage) — personne n’en est responsable"}],"structuredContent":{"alerts":[{"sorte":"en_retard","depuis":"2026-09-28","jours":4,"title":"Sans responsable","period_label":"Cadrage"},{"sorte":"sans_responsable","title":"Sans responsable","period_label":"Cadrage"}],"idle_unknown":[]}},"jsonrpc":"2.0","id":12}
get_kanban_status returns the columns, what moved this week (who, from what to what, when), and the same alerts — one call instead of piecing it together yourself:
{"jsonrpc":"2.0","id":13,"method":"tools/call","params":{"name":"get_kanban_status","arguments":{"schedule":"Suivi agents"}}}
{"result":{"content":[{"type":"text","text":"# État du kanban\n\n○ à faire : 1 · ◐ en cours : 1 · ✓ fait : 0\n\n## Cette semaine\n- 2026-10-02 05:51 · Xavier · « Rediger le plan » : à faire → en cours\n\n## Ce qui coince\n- ⏰ « Sans responsable » (Cadrage) — en retard de 4 j ouvrés (bloc terminé le 2026-09-28)\n- ❓ « Sans responsable » (Cadrage) — personne n’en est responsable"}],"structuredContent":{"columns":{"todo":1,"doing":1,"done":0},"moved_this_week":[{"title":"Rediger le plan","from":"todo","to":"doing","who":{"name":"Xavier"}}]}},"jsonrpc":"2.0","id":13}
Finally, palmaplan://me/actions is the same view as a resource — addressable without a tool call, and subscribable (resources/subscribe): on subscribing, the server watches every schedule you can read, and sends resources/updated as soon as one of them sees one of your actions change.
{"jsonrpc":"2.0","id":14,"method":"resources/read","params":{"uri":"palmaplan://me/actions"}}
{"result":{"contents":[{"uri":"palmaplan://me/actions","mimeType":"text/markdown","text":"# Mes actions\n\n1 action sur 1 planning :\n\n## Suivi agents\n- ◐ Rediger le plan — Tests (01a0fb2a-…)"}]},"jsonrpc":"2.0","id":14}
A limit worth knowing: the list of watched schedules is taken at subscription time. A schedule shared with you afterward is only picked up after you resubscribe (resources/unsubscribe then resources/subscribe again) — never sooner, never automatically.
8. Analyses: the pivot table (S27)
get_pivot_table returns a schedule's pivot table — the one on the "Analyses" tab, same engine: a measure crossed by up to two dimensions in rows (rows) and one in columns (columns), filtered if needed (filters). It is the tool for questions with a number in the answer: "how many days of workload for Anne in November?", "how many late blocks per phase?", "what share of actions is done, per assignee?". The text it returns is in French, like the rest of the server.
{"jsonrpc":"2.0","id":15,"method":"tools/call","params":{"name":"get_pivot_table","arguments":{"schedule":"Refonte","rows":["person"],"columns":["month"],"measure":"workload","today":"2026-10-25"}}}
{"result":{"content":[{"type":"text","text":"Tableau croisé de « Refonte » — charge (jours ouvrés des lignes reliées à une personne), en jours ; aujourd’hui = 2026-10-25.\nlignes : Personne · colonnes : Mois.\n\n| Personne | octobre 2026 | novembre 2026 | Total |\n|---|---:|---:|---:|\n| Xavier | — | 5 | 5 |\n| Anne | 5 | 5 | 10 |\n| **Total** | 5 | 10 | 15 |\n\nChaque total se calcule sur ce qu il couvre, jamais en additionnant les cases.\nid: 01a0f9e5-…"}],"structuredContent":{"config":{"lignes":[{"kind":"personne"}],"colonnes":[{"kind":"periode","pas":"mois"}],"mesure":"charge","filtres":[],"graphique":"aucun"},"unite":"jours","lignes":[[{"cle":"01a0f9e5-…","libelle":"Xavier"}],[{"cle":"01a0f9e5-…","libelle":"Anne"}]],"colonnes":[{"cle":"2026-10","libelle":"octobre 2026"},{"cle":"2026-11","libelle":"novembre 2026"}],"cellules":[[null,5],[5,5]],"totauxDeLigne":[5,10],"totauxDeColonne":[5,10],"total":15,"schedule":"01a0f9e5-…","today":"2026-10-25"}}},"jsonrpc":"2.0","id":15}
For Anne in November alone, filters by label are enough — "filters":[{"dimension":"person","values":["Anne"]},{"dimension":"month","values":["novembre 2026"]}] — and the answer fits on one line: Total : 5 jours. A value is recognised by its displayed label (case and accents ignored) or by the key in the structured content (2026-11); an unrecognised value is reported (unrecognized), never silently dropped, and a filter with nothing recognised is refused with the possible values. A period with no facts at all (2026-12, "décembre 2026", "December 2026", 2026-W50, "Q4 2026") and a schedule member who carries no row are still recognised: "Anne's workload in December?" answers Total : 0 jours, not an error.
- Measures:
blocks,activities,actions,milestones(counts);working_days(working days of the blocks — in hours on an hourly schedule);workload(the working days of rows linked to a person byset_row_member, always in days);progress(duration-weighted progress, in %);done_actions_share(share of actions done, in %);absence_days(days the person of a row is absent during one of their blocks). - Dimensions:
phase,activity,level,color,state(a block's state ontoday),progress_band,critical,week,month,quarter,person,assigneeandaction_status(action measures only), andcolumn:<label or id>for a text, status, traffic-light or icon column (column:Statut). - A dimension that does not cross with the measure (
assigneewithworkload) is refused —isError, the fault in French andcompatible_dimensions, the list of those that do: enough to correct the call without guessing. - Totals are computed over what they cover, never by adding cells up: a block spanning two months counts once in each month and only once in the total.
todaysets the date (or ISO instant) a block's state is judged on; without it, the server's current instant, likeget_progress_report. Beyond 60 rows or 24 columns, the table is truncated — the text and the structured content (truncatedsays how many there were); narrow it down with filters.- A viewer gets the same table as an admin; from an embedded view, nobody is named: "vous" (you) for the viewer, "Membre 1", "Membre 2"… for the others — the same numbering as the rest of the view —, "Personne 1" for a free-text name.
9. Spaces: dashboard, people, activities (S28)
A space groups projects (schedules); every account has its personal space. The space tools read them, read-only, with the rights of the person who connected the agent — the computation and the reader rule live in the API, the tool only formats:
list_spaces— my spaces: "Personnel" and the shared spaces I am a member of, with my space role and their number of projects;get_space_dashboard— the four figures (active projects, time allocated this month, open actions, blocks at risk) and a table over all the space's projects: measuresallocated_days,open_actions,risky_blocks; dimensionsproject,person,week,month,quarter,action_status,state; filters by label, as withget_pivot_table;get_person_view— a person's weekly workload against their capacity (5 days minus their absences), their actions in three columns, their recent activity;person: "me"for yourself;get_activity_view— each activity with allocated, spent, remaining time and overrun; "non estimé" (not estimated) is spelled out.
The space argument is an identifier, a name ("Transformation SI"), or personnel.
{"jsonrpc":"2.0","id":16,"method":"tools/call","params":{"name":"get_space_dashboard","arguments":{"space":"Transformation SI","rows":["person"],"columns":["month"],"measure":"allocated_days","today":"2026-10-26"}}}
The answer carries the four figures and the Markdown table as text, and chiffres and resultat as structured content — the shape of POST /api/v1/spaces/:sid/dashboard.
- The reader rule. For a reader of the space,
persononly takes "Vous", "Les autres membres" and "Sans personne"; totals stay those of the whole space. A filter on another person is refused (FILTRE_NON_RECONNU) without the error naming them, andget_person_viewonly opens one's own (person: "me") — otherwiseSPACE_CONTRIBUTOR_ONLY. - A project shared on its own does not reveal its space:
SPACE_NOT_FOUND, the answer of a space that does not exist.list_schedulesgives a schedule's space when you are a member. - Nothing is written here: creating a project in a space, or moving a schedule into one, happens in the app.
What comes next
- The full reference of tools, resources and prompts, generated from this same server — every tool, its parameters, required or not.
- Tokens and scopes — what
readandwriteallow, and what no token allows. - The embedded view — to show the schedule rather than describe it.