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_code | engå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_contact | e-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