# 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.27.0"}},"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.27.0` 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.

## 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 time spent in each 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\nTemps passé : < 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.

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