# 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:

```json
{
  "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](claude-desktop.md)), 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](claude-code.md) 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](jetons-et-portees.md)).

## 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:

```json
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"create_schedule","arguments":{"name":"Agent showcase"}}}
```

```json
{"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:

```json
{"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"}]}]}}}
```

```json
{"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](reference-tools.md#en-masse) — the tool the `plan_project`
prompt recommends.

## 5. Reading a schedule

By a tool — the full view, in readable Markdown:

```json
{"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:

```json
{"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):

```json
{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"set_row_member","arguments":{"schedule":"Refonte","row":"2.1","member":"anne@x.test"}}}
```

```json
{"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:

```json
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"list_absences","arguments":{"schedule":"Refonte"}}}
```

```json
{"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):

```json
{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"get_schedule_risks","arguments":{"schedule":"Refonte"}}}
```

```json
{"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.

```json
{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"list_my_actions","arguments":{}}}
```

```json
{"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:

```json
{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"get_action_history","arguments":{"schedule":"Suivi agents","action":"Rediger le plan"}}}
```

```json
{"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:

```json
{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"update_action","arguments":{"schedule":"Suivi agents","action":"Rediger le plan","period":"Tests"}}}
```

```json
{"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:

```json
{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"get_action_alerts","arguments":{"schedule":"Suivi agents"}}}
```

```json
{"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:

```json
{"jsonrpc":"2.0","id":13,"method":"tools/call","params":{"name":"get_kanban_status","arguments":{"schedule":"Suivi agents"}}}
```

```json
{"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.

```json
{"jsonrpc":"2.0","id":14,"method":"resources/read","params":{"uri":"palmaplan://me/actions"}}
```

```json
{"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.

```json
{"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"}}}
```

```json
{"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 by `set_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 on
  `today`), `progress_band`, `critical`, `week`, `month`, `quarter`, `person`, `assignee` and
  `action_status` (action measures only), and `column:<label or id>` for a text, status,
  traffic-light or icon column (`column:Statut`).
- **A dimension that does not cross with the measure** (`assignee` with `workload`) is
  refused — `isError`, the fault in French and `compatible_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.
- `today` sets the date (or ISO instant) a block's state is judged on; without it, the
  server's current instant, like `get_progress_report`. Beyond 60 rows or 24 columns, the table is truncated — the text and the structured
  content (`truncated` says 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: measures
  `allocated_days`, `open_actions`, `risky_blocks`; dimensions `project`, `person`, `week`,
  `month`, `quarter`, `action_status`, `state`; filters by label, as with `get_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`.

```json
{"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, `person` only 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, and
  `get_person_view` only opens one's own (`person: "me"`) — otherwise `SPACE_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_schedules` gives 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](reference-tools.md), generated
  from this same server — every tool, its parameters, required or not.
- [Tokens and scopes](jetons-et-portees.md) — what `read` and `write` allow, and what no
  token allows.
- [The embedded view](vue-embarquee.md) — to show the schedule rather than describe it.
