{"ok":true,"protocol":"ic-scheduling/1","service":{"origin":"https://sched.skew.site"},"operations":["get_availability","hold_slot","confirm_booking","propose_alternative"],"endpoints":{"member_index":"GET /v1/members/{handle}","get_availability":"GET /v1/members/{handle}/{slug}/availability?from=&to=&tz=","hold_slot":"POST /v1/members/{handle}/{slug}/hold  {start}","confirm_booking":"POST /v1/members/{handle}/{slug}/book  {start, hold_token?, booker:{name,email,note?}}","propose_alternative":"POST /v1/members/{handle}/{slug}/propose  {proposed_starts[], proposal_id?}","get_booking":"GET /v1/bookings/{id}?token=<cancel_token>","get_booking_ics":"GET /v1/bookings/{id}.ics?token=<cancel_token>","cancel_booking":"POST /v1/bookings/{id}/cancel  {token, reason?}","reschedule_booking":"POST /v1/bookings/{id}/reschedule  {token, start}"},"time":{"format":"ISO-8601 UTC with trailing Z","tz":"IANA name beside it"},"client_requirements":{"user_agent":"Send a descriptive User-Agent naming your agent and a contact or docs URL, e.g. \"acme-booker/1.2 (+https://acme.example/bot)\". Library defaults such as Python-urllib/* can be refused at the network edge before the request reaches this service.","content_type":"application/json on every request with a body"},"error_envelope":{"shape":{"ok":false,"error_kind":"<kind>","message":"<text>","retry_after_s":null},"kinds":["calendar_unreachable","daily_cap_reached","hold_expired","not_configured","not_found","outside_window","rate_limited","signature_invalid","signature_required","slot_taken","too_soon","transient","validation"],"note":"Parse the body, never the HTTP status. Every protocol response is HTTP 200, including failures."},"writes":{"idempotency":"Idempotency-Key header required on every write. A replay returns the SAME booking, never a second one. Retry freely.","signature":{"required":true,"alg":"ed25519","covered_fields_no_body":["@method","@authority","@target-uri"],"covered_fields_with_body":["@method","@authority","@target-uri","content-digest"],"freshness_window_seconds":60,"rfc":"RFC 9421 (signatures) + RFC 9530 (Content-Digest)","keyid":"the RFC 7638 thumbprint of your Ed25519 public JWK: SHA-256 over {\"crv\":\"Ed25519\",\"kty\":\"OKP\",\"x\":\"<x>\"} (exactly that, no whitespace), base64url without padding","authority":"sched.skew.site","target_uri":"https://sched.skew.site<path>[?<query>]","signature_input_no_body":"sig1=(\"@method\" \"@authority\" \"@target-uri\");created=<unix seconds>;keyid=\"<keyid>\";alg=\"ed25519\"","signature_input_with_body":"sig1=(\"@method\" \"@authority\" \"@target-uri\" \"content-digest\");created=<unix seconds>;keyid=\"<keyid>\";alg=\"ed25519\"","content_digest":"sha-256=:<standard base64 of SHA-256 over the exact body bytes sent>:","signature_base":["\"@method\": <METHOD>","\"@authority\": sched.skew.site","\"@target-uri\": <target_uri>","\"content-digest\": <Content-Digest>   (only when there is a body)","\"@signature-params\": <Signature-Input with the leading \"sig1=\" removed>"],"signature_header":"sig1=:<standard base64 of the 64-byte Ed25519 signature over signature_base joined by \\n>:","headers":["Signature-Input","Signature","Content-Digest (with a body)"],"compatible_with":"the ic-signed-agent skill's recipe, unchanged except that keyid is the key's thumbprint","supported_profile":"ic-signed-agent (the strict RFC 9421 subset above)","not_supported":{"what":"signatures built to the Web Bot Auth draft profile: a tag parameter (tag=\"web-bot-auth\"), expires or nonce parameters, or signature-agent as a covered component","result":"refused as signature_invalid","why":"Signature-Input must carry exactly created, keyid and alg, and the covered components must be exactly the lists above; anything else is refused rather than ignored, so an unverified parameter can never be mistaken for an honoured one. Sending a Signature-Agent HEADER is fine: it names your directory and is not signed.","status":"interoperability with that profile is a recorded follow-up, not built"},"reference_client":"scheduling/examples/signed_agent_book.py in the IC life repo"}},"write_lanes":{"signed_agent":{"available":true,"who":"any agent; no IC account needed","auth":"see writes.signature","key_sources":{"registered":{"available":true,"trust":"registered","how":"bound by an IC operator; not self-serve","budgets":"registered_key"},"self_registered":{"available":true,"trust":"self_registered","budgets":"unknown_key","endpoint":"POST /v1/agent-keys","body":{"jwk":{"kty":"OKP","crv":"Ed25519","x":"<base64url>"},"contact":"<optional, 200 chars>"},"proof_of_possession":"the registration request itself must be signed by the key being registered, with keyid = its thumbprint","response":{"ok":true,"keyid":"<thumbprint>","expires_at":"<ISO-8601 Z>"},"idempotent":"re-registering the same key returns the same keyid, costs nothing, and renews it","key_ttl_days":90,"limits":{"new_keys_per_source_ip_per_day":5,"new_keys_per_day_service_wide":100}},"directory":{"available":true,"trust":"directory","mode":"open","budgets":"unknown_key","header":"Signature-Agent: \"https://<your-host>\"","fetched_from":"https://<your-host>/.well-known/http-message-signatures-directory","rules":["https on port 443 only; the path is fixed, not taken from the header","the host must resolve only to public addresses","no redirects; 3s; 64KB; a JSON content type","your keyid must equal the RFC 7638 thumbprint of a key in that directory","cached 5 minutes; a failed fetch is cached 1 minute"],"hosts":"any public host"}},"stranger_lane":"open","members_only_meeting_types":"bookable only by registered keys and the IC web lane; to every other caller they read as not_found"},"ic_web":{"available":true,"who":"a person signed in to the IC site","booking_page":"https://www.immersivecommons.com/book/<handle>","note":"The IC site calls this service server-to-server after authenticating the person. It is not an API an agent can call."}},"budgets":{"window":"per UTC day unless noted","registered_key":{"holds":60,"confirms":20,"availability_reads_per_hour":600},"unknown_key":{"applies_to":["self_registered","directory"],"per_key":{"holds":10,"confirms":3},"per_signature_agent_site":{"holds":100,"confirms":30,"note":"directory keys only; shared by every key served from one site, where the site is the last two DNS labels of the Signature-Agent host"},"self_registered_pool":{"holds":200,"confirms":40,"note":"shared by every self-registered key together"},"per_member":{"holds":30,"confirms":3,"note":"shared by every unknown key booking the same member"}},"hold_to_confirm_ratio":{"floor":0.15,"enforced_after_holds":10,"window":"per UTC day, per key"},"on_kv_outage":"writes refused (rate_limited, retry_after_s=30); reads served"},"members":{"provisioning":"self_serve","url":"https://www.immersivecommons.com/scheduling","booking_page":"https://www.immersivecommons.com/book/<handle>","booker_notification":"Owner cancel and reschedule delete or replace the Google Calendar event with sendUpdates=all, so Google emails the booker. This service sends no email of its own. Every owner booking view carries booker_notification {via: google|none, state: queued|sent|none|unknown, note}: queued = the delete or insert is owed and Google will email when it lands; sent = Google emailed; none = no calendar event ever existed and nobody has told the booker; unknown = the event was already gone, the owner changed it in Google, or the delete failed, so we cannot say. Read that block; do not infer it from calendar.status. state:sent is claimed only when a real event was written or deleted.","reschedule":"One transaction: a refused move leaves the original booking untouched, and every failure envelope carries released:false. The new booking inherits the old one's cancel_token, so the booker's original manage link keeps working; the response carries rescheduled_from (alias previous_booking_id). The owner is not bound by min_notice; published windows, live free/busy and max_per_day still apply.","inbound_sync":{"what":"An edit the member makes in Google Calendar is observed by reading only the event this service created (fields id,status,start,end,updated). A deleted event cancels the booking (calendar.status cancelled_external); a moved event marks it rescheduled (moved_external) with rescheduled_to pointing at a new confirmed booking that adopts the event and the booker's cancel_token. Both carry actor google:owner in history and cancelled_by / rescheduled_by = google:owner on the row. A move onto a time overlapping another confirmed booking is a sync_conflict: the booking stays at its original time and calendar.error + calendar.external_start/end say where Google has it.","lag":"within about 10 minutes (SCHEDULING_CALENDAR_SYNC_MINUTES); calendar.observed_at on each owner booking view says when we last looked, null until the first look","swept":"confirmed bookings whose calendar.status is synced and that have not ended","not_observed":"a booker accepting or declining the invitation (attendees are never read); edits to events this service did not create"},"how":"Either on the IC site (sign in at the url above), or by the member's own agent through the IC MCP tools in `via_mcp`. Both write the same member record; the booking page is then https://www.immersivecommons.com/book/<handle>.","agents_can_provision":true,"agents_can_manage_bookings":true,"via_mcp":{"url":"https://www.immersivecommons.com/api/mcp","a2a_url":"https://www.immersivecommons.com/api/a2a","scope":"scheduling:manage","tools":["ic_scheduling_my_setup","ic_scheduling_claim_handle","ic_scheduling_set_profile","ic_scheduling_set_meeting_type","ic_scheduling_connect_calendar","ic_scheduling_set_live","ic_scheduling_my_bookings","ic_scheduling_disconnect_calendar","ic_scheduling_my_booking","ic_scheduling_cancel_booking","ic_scheduling_reschedule_booking"],"running_the_calendar":["ic_scheduling_my_bookings (list; from/to/status/limit)","ic_scheduling_my_booking (one booking + history, any date)","ic_scheduling_cancel_booking","ic_scheduling_reschedule_booking (start on the meeting type's grid, inside published hours; the owner is not bound by min_notice)"],"sequence":["ic_scheduling_my_setup (read what exists; returns next_steps in this order)","ic_scheduling_claim_handle","ic_scheduling_set_profile","ic_scheduling_connect_calendar (returns an authorize_url; see human_step)","ic_scheduling_set_meeting_type","ic_scheduling_set_live (refused until a calendar is connected and a meeting type is active)"]},"prerequisite":{"what":"an IC agent token (agt_...) carrying the `scheduling:manage` scope","human_approves_mint":true,"how":"IC's device-code signup (RFC 8628): the agent starts it and the person approves it at https://www.immersivecommons.com/signup-with-agent, or pastes a token minted at https://www.immersivecommons.com/floor10/agent-console. An agent cannot self-mint, and scopes cannot be added to an existing token, so mint one that includes `scheduling:manage`. Any signed-in IC account can hold that scope."},"human_step":{"what":"Google's consent screen: the member opens the authorize_url that ic_scheduling_connect_calendar returns and approves it themselves","why":"Google requires the account holder to grant calendar access. The member must leave all three calendar permissions ticked; the callback refuses a partial grant and stores nothing.","rules":["the link works once and expires after 10 minutes","if Immersive Commons asks the member to sign in, it must be the SAME account the token belongs to; the callback refuses anyone else"]},"nothing_else_needs_a_human":"Every member tool except ic_scheduling_connect_calendar needs no web sign-in and no approval beyond the token. Confirming an offer, a go-live, a cancellation or a move with the member first is advice, not a gate.","note":"The MCP tools reach this service through the IC site's server-to-server lane, after IC has authenticated the member's token. This service still accepts member writes from no other lane."},"member_endpoints":{"auth":"IC site server-to-server lane only (X-IC-Origin-Secret + X-IC-Member-* headers); reachable to agents through members.via_mcp","get_my_booking":"GET /v1/me/bookings/{id}","list_my_bookings":"GET /v1/me/bookings?from=&to=&status=&limit=","cancel_my_booking":"POST /v1/me/bookings/{id}/cancel {reason?}","reschedule_my_booking":"POST /v1/me/bookings/{id}/reschedule {start}","owner_availability":"GET /v1/me/meeting-types/{slug}/availability?from=&to=&tz=&exclude_booking= (same read, min_notice zeroed, min_notice_skipped:true; the named booking's own time is ignored so the picker matches what reschedule accepts)","budgets":"Owner cancel and reschedule have no KV budget in v1 (Postgres-only, they succeed through a KV outage) and count against no booker key; a reschedule still consumes the member's own max_per_day."},"calendar_status":{"synced":"on the member's Google Calendar; Google emailed the booker the invitation","pending":"being written","failed":"write or delete failed; calendar.error says why (member view only)","not_applicable":"no event will be written (write-back off, or no write-target calendar); nobody is emailed","cancel_pending":"delete owed; Google will email the booker when it lands","cancelled_synced":"deleted; Google emailed the booker a cancellation","cancelled_no_event":"cancelled; no event ever existed (write-back off, no write target, or cancelled before the writer wrote it), nobody was told","cancelled_already_gone":"cancelled; an event we had recorded was already gone when we went to delete it, so Google sent nothing on our behalf and we cannot say who told the booker","cancelled_external":"cancelled because the owner deleted the event in Google Calendar; whether Google told the booker depends on what the owner chose at that moment","moved_external":"the owner moved the event in Google Calendar; rescheduled_to owns the event now"},"holds":{"ttl_seconds":90,"max_concurrent_per_key":2,"note":"A hold is never a promise; confirm re-validates regardless."},"negotiation":{"max_counter_rounds":1,"escalation":null,"escalation_note":"No anonymous request lane exists yet. After the one COUNTER, a caller without an IC agent token has no further step on this protocol: book one of the offered slots, or contact the member out of band. An agent that holds an IC agent token (agt_) can send a request_meeting intent through IC's agent inbox, where a human decides; that lane is not part of this service."}}