hafn spec v0.5.2 · uppdaterad 2026-09-02 · maskinform: /docs.md

integrationen · den enda sida din agent behöver

docsprovisionering · nycklar · kalender · push

Allt en agent behöver för att integrera blint mot hafn. Uppmätt mot servern som kör — inte lovat.

Där verkligheten skaver mot standarden står det utskrivet. En spec som ljuger är värre än ingen.

1 · provisionering (invite-only)

Ett konto med adress, lösenord, kalender och en scopad API-nyckel skapas i ett anrop. Sedan v0.5 är hela maskinytan engelsk — det gamla /v1/konto svarar 410 med en pekare hit. Auktoritativt schema: /openapi.json.

POST https://hafn.eu/v1/account
Content-Type: application/json

{"invite_code": "hafn-xxxx-xxxx-xxxx",
 "account_name": "example",
 "admin_contact": "name@example.org"}
invite_codeengångskod från hafn · ^hafn(-[a-z2-9]{4}){3}$
account_name^[a-z][a-z0-9-]{2,29}$ — blir adressen account_name@hafn.eu
admin_contacte-postadress till kontots kontaktperson

Svar 201:

{"account": "...", "address": "...", "one_time_password": "...",
 "shell_token": "hafn_shell_...", "mcp_url": "https://hafn.eu/mcp",
 "scope": "household", "token_fingerprint": "...", "expires": null,
 "agent_permissions": ["..."], "jmap": "...", "imap": {...},
 "smtp": {...}, "important": "..."}

Lösenordet och skal-token visas en gång och lagras inte hos hafn. Felformen är {"error", "description"}: 400 ogiltiga fält · 403 ogiltig eller använd kod · 409 upptaget namn (koden förbrukas inte) · 429 för många försök · 502 bakomliggande fel (inget skapat, koden förbrukas inte).

Samma endpoint tar application/x-www-form-urlencoded och svarar då med en kvittosida i HTML — formulärvägen för människor. Fälten är desamma. Ingen kod? Se /invite.

2 · nyckeln och autentiseringen

Authorization: Bearer <shell_token>   (bara mot /mcp)

Skal-token (shell_token) gäller bara mot https://hafn.eu/mcp — mot JMAP direkt avvisas den med 401. Skalet är agentens enda väg in; människor loggar in med kontots eget lösenord i vanliga klienter.

Två sätt att få en skal-token. Provisioneringen (§1) ger en statisk token till en konfigurerad process. OAuth 2.1 är vägen för connector-klienter — t.ex. hafn som connector i Claude: /mcp utan token svarar 401 med WWW-Authenticate, metadata ligger på /.well-known/oauth-authorization-server, flödet är authorization code + PKCE med klientregistrering via Client ID Metadata Documents. Människan loggar in, bockar för scopes (mail.read mail.write mail.send cal.read cal.write — sändning förvalt av) och väljer giltighet. Access-token lever 24 timmar; refresh-token roterar och lever 30/90/365 dagar. Återkallning via POST /oauth/aterkalla dödar verktyg och ström direkt.

Behörigheterna sitter per JMAP-metod, i camelCase: jmapEmailGet, jmapCalendarEventCreate… Skrivning är delad per operation — jmapEmailCreate/Update/Destroy och jmapEmailSubmissionCreate; *Set-namn finns inte. Läget Replace ger exakt de listade metoderna, ingenting annat. Ett konto kan ha högst fem nycklar, och över taket svarar servern med det vilseledande felet invalidPatch — kontrollera antalet nycklar innan du felsöker något annat.

Nyckelns behörigheter är självbetjäning. Bara kontot självt kan ändra sina nycklar — admin får notFound, avsiktligt. En agent som håller kontots lösenord vidgar sin egen nyckel så här:

POST https://mail.hafn.eu/jmap/   (Basic account@hafn.eu:password)
using: ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"]

["x:ApiKey/get", {"ids": null}, "0"]        → nyckelns id + permissions-map
["x:ApiKey/set", {"update": {"<id>": {"permissions":
  {"@type": "Replace", "permissions": { ...hela nuvarande map:en...,
    "jmapEmailCreate": true, "jmapIdentityGet": true,
    "jmapEmailSubmissionCreate": true }}}}, "0"]

Tre uppmätta fällor: Replace betyder hela listan — utelämnade behörigheter försvinner tyst; permissions är en map {namn: true}, inte en array; och utgående post kräver även jmapIdentityGet — både utkast och sändning slår upp kontots avsändaridentitet. Hemligheten byts inte; skalet ser det nya scopet inom en minut (60 s behörighetscache) och verktygen dyker upp i tools/list av sig själva.

Rotation utan avbrott: skapa den nya, båda giltiga samtidigt, återkalla den gamla. Kontots lösenord påverkas inte. JMAP:s egen pushström prövar auktorisationen bara vid uppkopplingen — därför terminerar skalet strömmen självt och omprövar token löpande (§5): en återkallad token tappar strömmen inom sekunder.

3 · kalendersemantik

recurrenceRule är singular och ett objekt. RFC 8984:s pluralform recurrenceRules (array) avvisas med invalidProperties — även i sin enklaste form.

"recurrenceRule": {"frequency": "weekly", "until": "2026-12-31T17:00:00",
                   "byDay": [{"day": "th"}]}
"recurrenceOverrides": {"2026-09-03T17:00:00": {"start": "2026-09-03T18:30:00"}}

Tyst korruption, uppmätt: en starttid utan sekunder (2026-09-08T14:30) och ett naket datum accepteras båda av servern men lagras oläsbara — start saknas vid läsning och händelsen försvinner ur tidsfönster. Skalet normaliserar båda i hafn_cal_book och hafn_cal_update; en klient som går rakt på JMAP måste alltid skicka fullt HH:MM:SS. En redan skadad händelse repareras genom att uppdatera dess start.

until i recurrenceRule jämförs fel av servern: värdet läses som UTC men jämförs mot lokala starttider, så förekomsten exakt på until-tiden ryker — uppmätt: 17:00 CEST krävde until ≥ 19:00. Skalet kompenserar: until är en dag, hela dagen räknas med. En serie med interval > 1 ger dessutom noll förekomster från serverns expansion — skalets hafn_cal_agenda självexpanderar sådana serier, och sedan v0.5.1 med recurrenceOverrides respekterade.

Undantag är recurrenceOverrides, nycklade på förekomstens ursprungliga starttid. Tre förbehåll, alla uppmätta: de måste skrivas som helt objekt (JMAP-patchpekare avvisas med "Patch operation failed"); de expanderade förekomsternas id är syntetiska, positionella och oskrivbara; och läsning via ett syntetiskt id tappar ärvda fält och korrumperar duration. Skalet döljer alla tre — agendans rader bär stabila skrivbara id, och en ändring per förekomst skrivs som undantag på mästaren. Att avsluta en serie är att sätta until — ingen RRULE-sträng behöver någonsin tolkas.

status är förstklassigt: tentative | confirmed | cancelled, och överlever uttaget som STATUS:TENTATIVE.

Att skapa en händelse kräver calendarIds. Utelämnas fältet avvisas anropet med invalidProperties och "Event has to belong to at least one calendar." Hitta id:t med Calendar/get — isDefault markerar huvudkalendern. En uppgift läggs i sin egen kalender.

"calendarIds": {"b": true}

Heldag: showWithoutTime: true, en duration i dagar, ingen tidszon. Varning: expandRecurrences tappar heldagsflaggan och sätter Etc/UTC — även på händelser som inte är återkommande. Läs mästarna och expandera själv, eller gå genom MCP-skalet som rättar felet.

Sök serier som serier: CalendarEvent/query utan expandRecurrences ger mästarnas id. Samtidighet: ifInState (tillståndet är på typnivå). Ägarskap i delat konto: en Participant med calendarAddress — den enda bärare som uppmätt överlever en tredjepartsklient som skriver om händelsen.

4 · uppgifter

Det finns ingen Task/get, och urn:ietf:params:jmap:tasks finns inte i sessionen — leta inte. Uppgifter går genom CalendarEvent-metoderna med "@type": "Task" och progress i stället för status. Båda riktningarna är uppmätta: VTODO via CalDAV läses som Task i JMAP och tvärtom. En dedikerad lista skapas med MKCALENDAR och enbart VTODO i komponentuppsättningen; den syns i Calendar/get.

5 · push och billig ändringskontroll

Push finns för agenter, genom skalet. Två vägar: hafn_changed_since — ett verktyg som svarar "har något hänt sedan sist?" med en markör du skickar tillbaka, bär bara typ och tillstånd och passar en enhet som sover; och en SSE-ström på GET /mcp (Accept: text/event-stream, skal-token) för en process som är vaken, där skalet håller JMAP-strömmen internt och medlar den scopat vidare. Ingen återuppspelning — kör hafn_changed_since efter varje uppkoppling. Till skillnad från JMAP:s egen ström lyder den ditt scope och dör när din token återkallas. Optimera antalet väckningar, inte antalet bytes: TLS-handskakningen dominerar en batteridriven klient.

PushSubscription (webhook) fungerar inte. Prenumerationen lagras men ingen verifiering och ingen notis skickas någonsin — noll utgående paket, uppmätt. Bygg ingenting på den.

Känd lucka: JMAP:s egna strömmar lyder inte nyckelns scope — en nyckel utan läsrätt får ändå varje StateChange, och en öppen ström överlever återkallelse. Rapporterat uppströms; skalet är vägen runt tills det är lagat.

6 · gränser

Bilagor: rå bytes via hafn_mail_attachment, tak 25 MB. hafn tolkar aldrig innehållet — textutvinning är mottagarens ansvar.

Utgående post: 100 brev per dygn. Taket räknas per avsändaradress och på könivå — brev 101 avvisas inte utan skjuts upp till nästa UTC-dygn. Högre tak: POST /v1/request-quota {"account", "reason"} → 202; en människa läser och hör av sig.

Att komponera och att skicka är skilda behörigheter: hafn_mail_draft (jmapEmailCreate) skriver ett utkast som människan kan skicka från vilken mailklient som helst; hafn_mail_send (jmapEmailSubmissionCreate) skickar ett utkast direkt och beviljas separat av kontoägaren. Agentnyckeln har ingen sändning som standard.

7 · mcp-skalet och uttag

För agentklienter finns ett MCP-skal med verktygslista filtrerad per nyckel — hela ytan på /agents. Uttag: ett konto kan ta ut post och kalender i standardformat medan servern kör; hittills provat i liten skala, och det står hellre här än låtsas färdigtestat.

GET hafn.eu/docs.md · text/markdown · 200 · det här är exakt vad din agent hämtar

# hafn · docs — the integration

> This page is enough to integrate blind against hafn. Everything technical
> below is measured against the running server (Stalwart 0.16.19 on
> mail.hafn.eu) — not read from documentation. Authoritative schema for
> provisioning: https://hafn.eu/openapi.json
> spec v0.6.1 · 2026-09-04 · human form: https://hafn.eu/docs
> BREAKING: as of v0.5 the entire machine surface is English. /v1/konto and
> the Swedish field, tool and scope names are gone — hard cut, no aliases.
> The old path answers 410 with a pointer.

## 1. Provisioning  (invite-only)

An account with address, password, calendar and a scoped internal API key
is created in one call.

    POST https://hafn.eu/v1/account
    Content-Type: application/json

    {"invite_code": "hafn-xxxx-xxxx-xxxx",
     "account_name": "example",
     "admin_contact": "name@example.org"}

- invite_code    one-time code from hafn, pattern ^hafn(-[a-z2-9]{4}){3}$
- account_name   pattern ^[a-z][a-z0-9-]{2,29}$ — becomes account_name@hafn.eu
- admin_contact  email address of the account's contact person

Response 201:

    {"account": "...", "address": "...", "one_time_password": "...",
     "shell_token": "hafn_shell_...", "mcp_url": "https://hafn.eu/mcp",
     "scope": "household", "token_fingerprint": "...", "expires": null,
     "agent_permissions": ["..."], "jmap": "...", "imap": {...},
     "smtp": {...}, "important": "..."}

- one_time_password and shell_token are shown ONCE and are not stored by hafn.
- Errors: 400 invalid fields · 403 invalid or used code · 409 account name
  taken (the code is not spent) · 429 too many attempts · 502 upstream
  failure (nothing created, the code is not spent). Error form:
  {"error": "...", "description": "..."}.
- The same endpoint takes application/x-www-form-urlencoded and then answers
  with an HTML receipt page — the form route for humans. Same fields.
- No invite code? See https://hafn.eu/invite (hello@hafn.eu).

## 2. Authentication

    Authorization: Bearer <shell_token>

- The shell token is valid ONLY against https://hafn.eu/mcp. Against JMAP
  directly it is rejected with 401 — the shell is the agent's only way in.
  Humans sign in with the account's own password in ordinary clients
  (IMAP/JMAP/CalDAV); an agent never sees it.
- Two ways to get a shell token:
  1. Provisioning (§1) — a static token for a configured process.
  2. OAuth 2.1 — for connector clients where a human signs in and consents
     (e.g. hafn as a connector in Claude). Discovery: /mcp without a valid
     token answers 401 with WWW-Authenticate pointing at
     /.well-known/oauth-protected-resource/mcp; the authorization server's
     metadata is at /.well-known/oauth-authorization-server. Authorization
     code + PKCE (S256, mandatory). Client registration via Client ID
     Metadata Documents: client_id IS an https URL to the client's metadata
     document, and redirect_uri must appear in that document's
     redirect_uris. The resource parameter (RFC 8707) is validated when
     sent. Scopes: mail.read mail.write mail.send cal.read cal.write — the
     human ticks them on the consent page; sending is OFF by default. The
     access token is an ordinary shell token that lives 24 hours; the
     refresh token rotates on every use and lives 30/90/365 days as the
     human chose. Revocation: POST /oauth/aterkalla (RFC 7009) — tools and
     stream die immediately.
- Under the hood the shell holds one scoped JMAP key per tenant that never
  leaves the shell. Permissions are per JMAP method, in camelCase:
  jmapEmailGet, jmapCalendarEventCreate, jmapPushSubscriptionGet …
  Writing is split per operation: jmapEmailCreate / jmapEmailUpdate /
  jmapEmailDestroy and jmapEmailSubmissionCreate. *Set names do not exist.
  Replace mode grants exactly the listed methods, nothing else.
- An account can hold at most five API keys; above the cap the server gives
  the misleading error invalidPatch "Invalid key for object property".
- A key's permissions are SELF-SERVICE: only the account itself can change
  its keys (admin gets notFound — by design). To widen a key's scope, call
  as the account with its password:

      POST https://mail.hafn.eu/jmap/   (Basic account@hafn.eu:password)
      using: ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"]
      ["x:ApiKey/get", {"ids": null}, "0"]         -> key id + permissions map
      ["x:ApiKey/set", {"update": {"<id>": {"permissions":
        {"@type": "Replace", "permissions": { ...the ENTIRE current map...,
          "jmapEmailCreate": true, "jmapIdentityGet": true,
          "jmapEmailSubmissionCreate": true }}}}, "0"]

  Three measured traps: Replace means the WHOLE list — omitted permissions
  are silently dropped; permissions are a map {name: true}, not an array;
  and outbound mail needs jmapIdentityGet too — both drafting and sending
  look up the account's sender identity. The secret does not change; the
  MCP shell picks up the new scope within a minute (60 s permission cache)
  and the matching tools appear in tools/list by themselves.
- JMAP's own push stream checks authorization only at connect time — which
  is why the shell terminates the stream itself and re-checks your token
  continuously (§5). A revoked token loses the stream within seconds.

## 3. Calendar semantics  (measured against the server)

- Creating an event REQUIRES calendarIds. Omit the field and the call is
  rejected with invalidProperties and "Event has to belong to at least one
  calendar."

      "calendarIds": {"b": true}

  Find the id with Calendar/get — isDefault marks the main calendar. A task
  (@type: Task) goes in its own calendar, e.g. a VTODO-only list.

- recurrenceRule is SINGULAR and an object. RFC 8984's plural form
  recurrenceRules (array) is rejected with invalidProperties — even in its
  simplest form. Write singular:

      "recurrenceRule": {"frequency": "weekly", "until": "2026-12-31T17:00:00",
                         "byDay": [{"day": "th"}]}

- A start time WITHOUT SECONDS (2026-09-08T14:30) is accepted by the
  server but stored unreadable — same silent corruption as the bare-date
  case below, measured 2026-09-01. The shell normalizes both in
  hafn_cal_book and hafn_cal_update; a raw JMAP client must always send
  full HH:MM:SS.
- A BARE date as start is accepted by the server but stored unreadable —
  start is missing on read and the event vanishes from time windows (silent
  corruption, measured). The shell now normalizes all-day starts to
  T00:00:00 in hafn_cal_book and hafn_cal_update; a raw JMAP client must
  send a full LocalDateTime itself. An already damaged event is repaired by
  updating its start.
- A series with interval > 1 (every second week, every third Wednesday …)
  is accepted and stored correctly but yields ZERO occurrences from
  expandRecurrences — measured. The shell's hafn_cal_agenda self-expands
  such series; a raw JMAP client must expand itself. Since v0.5.1 the
  shell's own expansion honors recurrenceOverrides too — moved and
  excluded days come out right.
- until in recurrenceRule is compared WRONG by the server: the value is
  read as UTC but compared against local start times, so an occurrence
  exactly at the until time is dropped (measured: 17:00 CEST required
  until ≥ 19:00; in Etc/UTC correctly inclusive). The shell compensates:
  until in hafn_cal_end_series and hafn_cal_book is a DAY — the whole day
  counts. A client going straight at JMAP with until must compensate itself.
- Exceptions are recurrenceOverrides — a map keyed on the occurrence's
  ORIGINAL start time, with the deviating fields as value:

      "recurrenceOverrides": {"2026-09-03T17:00:00": {"start": "2026-09-03T18:30:00"}}

  Three caveats, all measured 2026-09-01: (1) overrides must be written as
  the WHOLE object — a JMAP patch pointer ("recurrenceOverrides/2026-…")
  is rejected with "Patch operation failed". (2) The ids of expanded
  occurrences are synthetic, POSITIONAL and unwritable: CalendarEvent/set
  answers "Updating synthetic ids is not yet supported", even for one-off
  events, and the ids change meaning whenever the series changes.
  (3) Reading an overridden occurrence by its synthetic id loses inherited
  fields and corrupts duration. The MCP shell hides all three: agenda rows
  carry stable writable ids, occurrence-level edits are written as
  overrides on the master, and fields are always read from the master.

- Ending a series is setting the until field. No RRULE string ever needs to
  be parsed or written.
- status is a first-class field: tentative | confirmed | cancelled. It
  survives export as STATUS:TENTATIVE in iCalendar.
- All-day: showWithoutTime: true, a duration in days, no timeZone.
  WARNING: expandRecurrences drops showWithoutTime and sets
  timeZone: "Etc/UTC" — even on events that are not recurring. Read the
  masters and expand yourself, or go through the MCP shell which corrects
  the bug. A client going straight at JMAP with expandRecurrences inherits
  it.
- Search series as series: CalendarEvent/query WITHOUT expandRecurrences
  returns the masters' ids, not the occurrences.
- Concurrency: send ifInState in CalendarEvent/set. The state is type-level
  (the whole calendar account) — a conflict may concern another event.
- Ownership in a shared account: mark the person as a Participant with
  calendarAddress (e.g. mailto:rut@hushall.hafn.eu — the address needs no
  mailbox). It is the only carrier measured to survive a third-party client
  rewriting the event; keywords, categories and color do not.

## 4. Tasks

- There is no Task/get and urn:ietf:params:jmap:tasks is not in the
  session. Do not look for them.
- Tasks go through the CalendarEvent methods with "@type": "Task" instead
  of "Event", and progress (e.g. needs-action) instead of status. Both
  directions are measured: a VTODO added via CalDAV reads as a Task in
  JMAP, and a Task created via CalendarEvent/set becomes a correct VTODO in
  CalDAV.
- A dedicated list is created with CalDAV MKCALENDAR and a component set of
  VTODO only; it appears in Calendar/get like any calendar.

## 5. Push and cheap change detection

Push goes through the shell, never JMAP directly — an agent holds no JMAP
credential. Two ways, both scoped and revocable (unlike JMAP's own stream):

- WEBHOOK (v0.6, preferred) — hafn_push_subscribe registers your own public
  https URL; the shell POSTs a signed notification the moment something
  changes, and a heartbeat every 900 seconds even when nothing does. Body:
  {"type","state","account","ts"} — type and state only, never content.
  Signature: X-Hafn-Signature: sha256=hex(HMAC-SHA256(secret,
  X-Hafn-Timestamp + "." + raw body)); reject a timestamp older than five
  minutes and compare in constant time. Answer 2xx within 5 s. Retries at
  0/30/120 s; ten consecutive failed deliveries deactivate the subscription,
  which is audit-logged and announced in the account owner's inbox. The full
  recipe, with examples: https://hafn.eu/agents.md
- hafn_changed_since — a tool that answers "did anything happen since last
  time?". The first call returns a marker; send it back next time for a
  delta. Carries only data type and new state, NEVER what changed. As of
  v0.6 it accepts ONE CALL PER 5 MINUTES per token: it is the emergency
  exit, not the main road. The refusal is self-correcting and consumes
  nothing. As of v0.6.1 the marker carries TWO state sources per type (the
  change log and the query state) and reports a change if EITHER moved —
  a raw-JMAP client that trusts Email/changes alone can be told that nothing
  happened while new mail is sitting in the mailbox (measured; finding 18).
- SSE: GET https://hafn.eu/mcp with "Accept: text/event-stream" and the
  shell token, for a process that stays awake. The shell holds the JMAP
  stream internally and relays StateChange scoped; keepalive every 20
  seconds. Events: hello, change, reset, end. Event types: calendar, mail
  (any mail-state change: flags, moves, deletes, new mail) and mail_new
  (mail actually arrived — the one worth a fetch). Measured: a flag change
  emits mail alone, a delivery emits mail_new and mail.

Replay (v0.6.1): every change event carries an id:. Send the last id you
HANDLED as the Last-Event-ID header when you reconnect; the hello event's
replay field then says fresh | resumed | none-missed | unavailable. Only
sweep when it says fresh or unavailable — "none-missed" is stated only when
the server can prove it. The shell also coalesces a burst into at most one
event per type per second, so no client-side debounce is needed. A stream
may also end without an end event (dropped connection, proxy timeout): that
is normal — reconnect with Last-Event-ID.

When a notification says something happened, fetch it with the tool for that
type: hafn_mail_search for mail and mail_new, hafn_cal_agenda (or
hafn_cal_series) for calendar.

Cost for a battery-powered client: the payload of a narrow delta is ~667
bytes up and 500 down, but with the TLS handshake ~6.5 kB versus 12.9 kB
for a full week sync. The handshake dominates — optimize the number of
wakeups, not the number of bytes. A webhook removes the handshake from the
client side entirely: the client makes NO call at all until hafn calls it,
and a missing heartbeat is the only thing it ever has to watch for.

## 6. Limits

- Attachments: fetched as raw bytes via hafn_mail_attachment, cap 25 MB.
  hafn never parses the content — text extraction is the recipient's
  responsibility.
- Outbound mail: 100 messages per day. The cap counts per sender address
  and at queue level: message 101 is not bounced but deferred to the next
  UTC day. Higher cap: POST https://hafn.eu/v1/request-quota
  {"account": "...", "reason": "..."} → 202; a human reads the request and
  gets in touch. Or hello@hafn.eu.
- The agent key has no sending by default. Composing and sending are
  separate: hafn_mail_draft (jmapEmailCreate) writes a draft the human can
  send from any mail client; hafn_mail_send (jmapEmailSubmissionCreate)
  sends a draft directly and is granted separately by the account owner.

## 7. The MCP shell

For agent clients there is an MCP shell (streamable HTTP) over the same
data, with the tool list filtered per key scope. The whole surface:
https://hafn.eu/agents.md

## 8. Export

An account can export mail and calendar in standard formats while the
server runs — the export stops no one else. Tested at small scale so far;
the formats are ordinary mbox/ICS that other systems read.

## Source of truth

The field names in §1 are verified against https://hafn.eu/openapi.json and
https://hafn.eu/llms.txt. If any of this changes, this document changes
first. Questions: hello@hafn.eu