tk-link — tktk.link ===================== The backbone provides QUIC tunnels: a backend agent opens one outbound UDP socket, and clients reach its service at .tktk.link:443. The edge authenticates each client and hands the established QUIC connection to the agent. Each bidirectional stream carries your application's bytes. TCP/TLS fallback is available on the same service SNI and port. Each socket becomes a stream in a gateway's pooled QUIC connection. TCP-only providers use sdk/go/cmd/tk-agent: one TLS registration and on-demand data connections. Everything authenticates with a TLS 1.3 CLIENT CERTIFICATE. You generate a keypair, self-sign it, and register the public half; the private key never leaves your machine and the backbone stores nothing that could be leaked or replayed. Tk-link service authentication accepts no bearer string, identity header or signed URL. Apple developer tokens authorize Apple calls. SEPARATE HOSTNAMES, because a certificate is demanded during the HANDSHAKE and that decision is made per SNI name, before any request exists. Every path is served on exactly one of them; the wrong host answers 421, never a redirect. api.tktk.link THE MANAGEMENT API. Its programmatic surface excludes registration and gateway calls: the management calls below (client certificate) and the admin calls (Bearer token). The handshake asks for a certificate and lets you decline — declining is how the admin token works — so a call that needs to know who you are and got no certificate answers 401. provision.tktk.link REGISTRATION, and nothing else. The one place a machine with no registered key can be heard: it accepts a certificate the backbone has never seen, where every other name refuses an unknown key inside the handshake, before HTTP exists. Nothing reachable here grants anything, which is why it is a name of its own. tktk.link The console, for humans in a browser. NO certificate is requested at all — a browser prompted for one is a dead end. Sign-in, `/healthz` and this page live here; `/v1/*` does not. .sites.tktk.link PUBLIC STATIC SITES, served to anyone. Every user may publish one website from a bucket they own. Like the console, NO certificate is requested. GET and HEAD only, every page sandboxed, never a listing. See PUBLIC SITES below. models.tktk.link THE LLM GATEWAY, per user. An OpenRouter-compatible API: point your client's base_url at https://models.tktk.link/api/v1 and connect WITH your certificate — REQUIRED in the handshake here, like any service. Your first request mints you your own upstream key and your own api service behind the scenes; every request after rides them, so usage is YOURS (GET /api/v1/key shows it). No grant to ask for: the certificate is the grant. See FOR LLM CLIENTS below. openai.models.tktk.link THE SAME THING, straight to OpenAI. base_url https://openai.models.tktk.link/v1 and your certificate; your first request mints you your own OpenAI key. Independent of models.tktk.link: a separate key, a separate service, separate usage — and either host may be configured without the other. gemini.models.tktk.link GEMINI, through the Gemini Developer API. Your path travels verbatim, so address it exactly as you would Google's own endpoint: POST https://gemini.models.tktk.link/v1beta/interactions That ONE endpoint is the whole surface today — not :generateContent, not the model list. Google offers no way to create a Gemini key programmatically, so nothing is minted for you here: every caller goes out on one shared key, and reading interactions back is refused precisely because that key makes everyone the same consumer upstream. Model names are Google's and they ROT: an older one answers 404 with "no longer available to new users", which reads like a bad path but is not. gemini-flash-latest and gemini-pro-latest do not rot. Each gateway serves an ALLOWLIST of endpoints: inference and model metadata, including POST /v1/responses on OpenAI. Responses token usage is recorded from JSON replies and streaming events. Files, vector stores, batches, and reading stored completions or responses are not served. Anything else is 404 endpoint_not_allowed. Your key lives in a shared upstream project, and reads there are not tenant-scoped. Keep response and conversation ids private: creation can reference existing state by id, even though direct retrieval is blocked. APPLE MUSIC — https://music.apple.tktk.link Catalog GET requests are proxied under /v1/catalog and /v1/storefronts. POST /v1/developer-token returns {token, expiresAt, expiresIn}, signed with the existing shared Apple key, valid for 20 minutes. Both surfaces require a registered mTLS device and its Music service grant. Every registered device is eligible; principal revocation is the durable device control because gateway reprovisioning can restore a grant. Token issuance returns 429 rate_limited for bursts, 503 models_not_configured for missing Apple credentials, or 503 token_refresh_failed for bad keys. No deployment changes or new secrets are needed. Accepted policy: the team JWT may also authorize Maps token exchange or other capabilities enabled on the shared key. Direct Apple calls bypass gateway allowlists and accounting until expiry. See docs/PROTOCOL.md section 12. ── 0. GET AN IDENTITY (once) ─────────────────────────────────────────────── 1. Generate a keypair and self-sign a certificate. Compute the fingerprint the backbone keys you on: openssl x509 -in cert.pem -pubkey -noout \ | openssl pkey -pubin -outform DER \ | openssl dgst -sha256 2. Ask a human to recognise it — CONNECTING WITH THAT CERTIFICATE: curl --cert cert.pem --key key.pem \ -X POST https://provision.tktk.link/v1/principals/requests \ -d '{"name": "laptop"}' There is no key in the body and none is read from one: the fingerprint registered is the one taken off this TLS connection. Completing the handshake signs it with your private key, so it proves possession — whereas a public key in a request body proves nothing, and could be anyone's. Connect without a certificate and you are told to bring one. Response: an authorize_url, a short user_code, and a poll_secret. 3. Show the user_code, then hold a long poll open: GET https://provision.tktk.link/v1/principals/requests/ x-principal-request-secret: {"status": "pending"} means "reconnect immediately". On approval you get {"status": "approved", "principal": {...}} — your principal UUID. Nothing in that response is a secret. Open the authorize_url while signed in, check the code matches, approve. The approver chooses themselves or a managed child as the principal owner. Their browser session stays under the adult's identity; child devices can be revoked from /console/users. On an embedded device whose OWN browser opens that link, append ?embedded=1: the approval page then closes its tab on success instead of linking to a console the device cannot use. Best-effort — a normal desktop tab ignores the scripted close and just shows a "you can close this tab" button. ── ON-DEVICE CHILD SETUP (Apple or Google) ───────────────────────────────── For a device that MUST belong to a child, open this on that same device: https://tktk.link/login?setup=child&device_id=&spki_hash=&app_redirect= device_id is the app's stable device identifier. spki_hash is the lowercase hex SHA-256 of the local key's DER SubjectPublicKeyInfo (64 characters). Generate and retain the private key on the device; only its fingerprint is sent. URL-encode each query value. app_redirect is optional and must match the server's redirect allowlist when configured. 1. The PARENT signs in with their own Apple or Google account on the device. 2. The parent must select an existing child profile or create one. The child needs no email or Apple/Google login. There is no "Myself" option in this mode; the server rejects adult selection, including a tampered form. 3. Confirm setup. The local key is registered as a principal under the CHILD'S user_id; authorized_by_user_id records the parent. The parent's browser session is unchanged, and no parent principal is created first. 4. The browser redirects to app_redirect with a one-time code. Redeem it: POST https://tktk.link/auth/exchange Content-Type: application/json {"code": ""} Response: {"principal": {...}, "user": {...}} for the selected child. Without app_redirect, a success page is shown instead. Setup authorization expires after ten minutes and is consumed on selection. The child requirement survives Apple/Google callbacks and account linking. After setup the device authenticates with its own certificate and the child's permissions; the parent's service grants do not pass down to the child. To switch users, reset setup with a new device key and authorize again; revoke the old principal to end its access. No QR code or second device is required. Omitting setup=child keeps the general self-or-child setup flow. /console/users also lets parents create profiles and revoke child devices. ── 0a. SIDECARS (if your tool is not certificate-aware) ──────────────────── For the HTTP APIs and S3 storage below, tools that cannot present a client certificate can use the sidecar from sdk/go: it owns the keypair, exposes a plain unauthenticated localhost endpoint, and attaches the certificate on the way out. Your tool's config becomes http://127.0.0.1:. QUIC tunnel clients use the QUIC configuration in FOR CLIENTS below. ── FOR LLM CLIENTS (the models gateways) ────────────────────────────────── Three upstreams, one contract. Nothing to create and no grant to request — your certificate is the key: curl --cert cert.pem --key key.pem https://models.tktk.link/api/v1/key curl --cert cert.pem --key key.pem https://openai.models.tktk.link/v1/models curl --cert cert.pem --key key.pem -H 'content-type: application/json' -d '{"model":"gemini-flash-latest","input":"hi"}' https://gemini.models.tktk.link/v1beta/interactions The first call provisions: your own upstream key (minted for you, sealed, never shown) and a kind=api service you OWN that carries it — it appears in GET /v1/services like any service, and deleting it just means the next call provisions a fresh one. That call and every one after are proxied to the LLM upstream with YOUR key, so usage is attributed to you; GET /api/v1/key reports it. Any OpenAI-compatible client works: base_url https://models.tktk.link/api/v1 and any api_key string at all (it is stripped — the certificate is what authenticates), or through the sidecar (§0a) for tools that cannot present a certificate. 503 models_not_configured means this deployment has no LLM upstream configured. openai.models.tktk.link works exactly the same way against OpenAI itself (base_url https://openai.models.tktk.link/v1). Your key there is a per-user OpenAI service account, so your usage is attributed to you at OpenAI — but note OpenAI returns only token counts, never a cost, so there is no equivalent of /api/v1/key to ask what you have spent. gemini.models.tktk.link is the same contract with two differences you can see. Nothing is minted for you — Google has no create-key API, so every caller shares one key and there is no per-user usage figure to report. And it serves exactly ONE endpoint, POST //interactions; anything else, model list included, is 404 endpoint_not_allowed. Reading interactions back is refused on purpose: with one shared key upstream, a list or a fetch by id would return whatever anyone stored. Your body and path are forwarded byte-for-byte, so point a google-genai client at this host as its base URL, or just POST the JSON yourself. The three hosts are independent: one answering 503 says nothing about the others. ── FOR BACKEND SERVICES ──────────────────────────────────────────────────── 1. Create a service you own POST https://api.tktk.link/v1/services (client certificate) Optional body: {"name": ""} — a non-unique label shown in the console so you can tell services apart. Response: {"service": "", "name": ""} — QUIC clients address your tunnel at .tktk.link:443 over UDP. (The owner is auto-granted access to its own service.) Rename later: PATCH https://api.tktk.link/v1/services/ {"name": "..."} (owner only; blank or null clears it). 2. Start the QUIC handoff agent See QUIC AGENT below. The agent authenticates with its registered client certificate and must have a grant for this service. Its one UDP socket carries the control connection and every handed-off client connection. 3. What your backend receives The agent adopts the authenticated QUIC connection. For each client bidirectional stream it opens a connection to your local upstream, such as 127.0.0.1:8080, and copies application bytes in both directions. Stream FIN half-closes the corresponding direction; concurrent streams use separate upstream connections. The local service receives raw bytes. The verified caller identity and client address arrive at the agent as handoff metadata, separate from the application stream. The example agent does not add that metadata to your local service's byte stream. Bind the upstream to localhost when it should be reachable only through the agent. 4. List what you can reach GET https://api.tktk.link/v1/services (client certificate) Response: {"owned": [...], "accessible": [...]} — "owned" is every service whose owner is you; "accessible" is every service you hold a grant on but do NOT own. Each entry carries its public "url", its "name", its "kind" and a live "connected" flag (true when the URL answers right now). On "accessible" entries "kind" is "bucket" or "gateway" — the same pair you declare when requesting access; tunnel and api services are both gateways and are not told apart. "owned" entries carry the registry's kind ("tunnel", "api", "bucket"): what is behind your own service is your business. Scoped to your certificate: there is no way to enumerate services you neither own nor were granted. 5. Delete a service you own DELETE https://api.tktk.link/v1/services/ (client certificate) 204 on success. Owner only: 403 for any other principal, 404 for an unknown service. ── QUIC AGENT ───────────────────────────────────────────────────────────── The reference agent is sdk/go/cmd/handoff-agent. It needs Go, Rust, CMake and a C compiler to build the patched QUIC library used for adoption. From a checkout of https://github.com/tktkinc/tk-link: git submodule update --init vendor/quiche make -C sdk/go run-agent \ ARGS="-addr :443 -sni agents.tktk.link \ -service -cert /absolute/path/cert.pem \ -key /absolute/path/key.pem -upstream 127.0.0.1:8080 \ -insecure=false" Replace with an IPv4 address resolved from agents.tktk.link; -addr requires a numeric IP and port. Keep -sni set to agents.tktk.link and -insecure=false so the agent verifies the backbone's certificate. Start your local service on 127.0.0.1:8080, or change -upstream. The agent connects over UDP :443 with ALPN tklk-ctl/1. Its control stream exchanges HELLO/HELLO_ACK, then SERVE/SERVE_OK for your service. A SERVE_ERR means registration failed; check the certificate, service UUID and grant before treating the backend as ready. The control channel carries connection handoffs, caller identity, revocation and reconnect messages. It also keeps the agent's NAT mapping alive. Client data uses separate QUIC connections adopted on that SAME UDP socket. Allow outbound UDP :443 and its replies. No inbound port forwarding is required. The agent retains the UDP socket and adopted connections while reconnecting its control channel after an edge deploy. The edge authenticates each new client and checks its service grant before handoff. Afterwards packets go to the agent's observed UDP address and port. BPF accelerates forwarding; userspace routing keeps tunnels working while maps are absent and refills them when BPF returns. OPTIONAL DIRECT DELIVERY — STEP BY STEP Normally the edge forwards encrypted packets between the client and agent: client <-> edge public IP:443 <-> backend agent <-> local service To try removing that middle forwarding hop, add -use-preferred-port when starting the agent, or set HandoffConfig.UsePreferredPort: true in Go. This is off by default. Here is what happens when it is enabled: 1. The backend opens its control connection to the edge. It sends from a UDP socket on the backend machine. If the backend is behind a router, NAT translates that socket to a public IP and port. For example, local port 50000 might appear on the internet as port 62000. The edge observes the public address; it does not guess the local port. The agent keeps this SAME socket open for control and client traffic. 2. A client connects to the edge for this service. The edge selects the backend before sending its TLS handshake response. In that handshake, QUIC's preferred_address parameter tells the client the backend's observed public IP AND UDP port — 62000 in the example. This is an invitation to test another network path, not an instruction that the client's router must accept. The client can ignore it. 3. The edge authenticates the client and hands the connection to the agent. The edge completes the handshake, checks the certificate and service grant, then transfers the established connection's keys and state over the encrypted control channel. The HANDOFF frame carries the real client's public IP and UDP port from the validated exported QUIC path. That is the client's address seen by the edge, not the edge's own IP. It can differ from the original address if the client's NAT mapping changed. Client data has its own QUIC connection; it is not a stream inside the backend's control connection. 4. The backend immediately sends a tiny packet toward that client address. After adopting the connection, before sending HANDOFF_ACK, the backend races one UDP datagram containing a single 0x00 byte to the client. It sends from the SAME socket used for control. Opening another socket could create a different NAT mapping and would not help the advertised port. The client discards this invalid QUIC datagram. No reply is expected, and a send failure does not fail adoption. Why send something the client will discard? Some routers admit packets from a remote IP and port only after the backend has sent toward that destination. The outbound packet can open that permission. Permit outbound UDP to client ports when enabling this option, in addition to the normal UDP :443 control path. 5. Meanwhile, the client tests the preferred backend address. A supporting client sends QUIC path-validation probes to the advertised backend IP and port. This races the backend's outbound 0x00 packet; the two packets do not have to arrive in a particular order. The no-op is only a NAT hint. Actual QUIC probes and responses establish whether the authenticated connection can communicate over the direct path. 6. If validation succeeds, the client can switch the existing connection. client <-> backend agent <-> local service The keys, connection and streams continue. All streams move together; this does not create a fresh handshake or move individual streams to different backends. The control channel remains connected to the edge for handoffs and authorization. Direct service uses a bounded control lease, so losing the control plane does not permit indefinite access. This is an attempt, not guaranteed NAT traversal. A router may assign a different public source port when the backend sends toward the client. The client's port may also change when it sends toward the backend instead of the edge. The no-op cannot discover those different mappings. If initial preferred-path validation fails, the client keeps using the VIP relay. Automatic failback after a successful switch is not guaranteed. This option applies to tunnel ALPN tklk-t/1, not ordinary browser HTTP/3 connections. Full protocol and SDK contract: docs/QUIC_HANDOFF.md in the repository. ── FOR CLIENTS ──────────────────────────────────────────────────────────── 1. Get an identity (section 0 above). 2. Get access to a service Accept an invitation from the service owner. One link grants access to MULTIPLE services together: Owner: open https://tktk.link/console and click Invite (or go to https://tktk.link/console/invites). Select the services to share, give each a label and description, and choose principal-level or user-level access for the whole bundle. Buckets may be read-only. Create the link and send it to the recipient; you do not need their account or principal ID to create the invitation. Recipient: open https://tktk.link/invites/ and review every service's label, description and permissions. Opening the link grants nothing. Confirm the current account or sign in to a different account; sign-in brings you back to the invitation. * User-level: "Use this account and accept access" grants all services to that user's current and future principals. * Principal-level: "Use this account", then choose one of that account's active principals and accept access to all services. If there are none, register a principal from your device and return to the link. The bundle is granted all at once. Existing grants keep their current permissions; principal-specific grants take precedence over user grants. Anyone holding a valid link can claim it once. Links expire after seven days; owners can revoke unused links on the invitations page. After acceptance, use the normal grant controls to remove access — revoking an invitation does not undo accepted grants. Or the service OWNER grants a principal or user access directly: POST https://api.tktk.link/v1/services//grants (owner's client certificate) {"principal_id": ""} or {"user_id": ""} for that user's current and future principals. Supply exactly one of principal_id or user_id. Or you request access yourself and an admin approves the request: POST https://api.tktk.link/v1/access-requests (client certificate) {"service": "", "kind": "gateway"} "kind" is REQUIRED and must match the service: "bucket" for an S3 bucket (its grants carry data capabilities), "gateway" for anything you send requests through. A missing or non-matching kind is a 422 — you must not end up holding bucket access you believed was a gateway grant, or the reverse. There is no third value: every non-bucket service is a gateway to callers, whatever fulfils it. Response: {"request_id": "...", "user_code": "WDJB-MJHT", "kind": "gateway|bucket", "approve_url": "https://tktk.link/access/", ...} Then get it decided, either way round: * Send approve_url to whoever owns the service, however you like. It is a browser page: a signed-in OWNER of the service gets Approve/Deny, and every other viewer — you included — gets the same request details read-only with its current status. Holding the link decides nothing, so it is safe to pass on, and both of you watch the same page. * Or quote the user_code to an admin. They list what is outstanding (GET /v1/admin/access-requests), match the code, and approve or deny with the admin token — so they never have to act on an identifier a stranger sent them. Then long-poll your own request until it is decided: GET https://api.tktk.link/v1/access-requests/?wait=25 (client certificate) {"status": "pending"} means "reconnect immediately". The client certificate authorizes the poll, so nobody else can read the outcome; there is no poll secret and no signed URL. Changed your mind? DELETE https://api.tktk.link/v1/access-requests/ (client certificate) Approval normally grants the requesting principal access. The owner's browser page can instead grant the requesting user access on all devices. The recipient is resolved from the request, never chosen by the approver. This request/approval flow is separate from owner-created invitations. Requests are stored, single-use (a second approval is a 409), withdrawable, and expire on their own after 24h. (Admins can also grant directly: POST /v1/admin/grants.) 3. Call a service For a QUIC tunnel, use a standard QUIC library such as quic-go. You need no handoff library or native SDK on the client: Address: .tktk.link:443 over UDP TLS SNI: .tktk.link ALPN: tklk-t/1 Client cert: your registered cert.pem and key.pem Server trust: normal certificate and hostname verification In quic-go, load your keypair with tls.LoadX509KeyPair, set tls.Config.Certificates, ServerName and NextProtos to the values above, and call quic.DialAddr. Open a bidirectional stream with OpenStreamSync for each local upstream connection you need. Write your application's bytes and read its replies. Stream.Close sends FIN on the write side; continue reading until the peer finishes. Multiple streams can share one QUIC connection. This ALPN carries raw application streams, not HTTP/3 requests; use your application's own framing and request deadlines. Authentication and authorization failures close the QUIC connection; they are not HTTP status codes. If the local service returns after an outage, open a new stream. If the agent process restarts, establish a new QUIC connection. Retry operations only when your application permits it. For the HTTP API and storage services below, use HTTPS with your client certificate. HTTP refusals use status codes such as 401 and 403. 4. Optional: object storage (S3-compatible) Create a bucket. A bucket IS a service: it gets a UUID that is its .tktk.link subdomain, appears in your service list, and is shared by GRANTING principals access to it — exactly like a tunnel. POST https://api.tktk.link/v1/storage-buckets (client certificate) {"name": "primary db"} Response: {"bucket": "", "url": "https://.tktk.link", "endpoint": "https://storage.tktk.link", "region": "auto"} Reach the data plane two ways: - virtual-host — https://.tktk.link/ - path-style — https://storage.tktk.link// Identity is purely the certificate. An inbound Authorization: AWS4-... is TOLERATED and ignored: S3 tooling that demands a keypair can put any value in it (the bucket UUID is a convenient dummy), and anonymous requests are fine too. Neither grants anything. Point the tool at a sidecar and it needs to know nothing about any of this. Share it by granting another principal, with capabilities: POST https://api.tktk.link/v1/services//grants {"principal_id": "", "access_mode": "ro", "key_prefix": "logs/"} access_mode is "ro" (read-only) or "rw" (default); key_prefix scopes a read-only grant to keys under that prefix. Read-only / prefix grants are enforced at the storage layer itself. The owner is auto-granted full. Real dedicated bucket — copy, multipart, etc. all work; request bodies stream (no gateway object-size limit). SSE-C (customer-provided keys, x-amz-server-side-encryption-customer-*) is supported on every operation; SSE-S3 / SSE-KMS headers are rejected with 400, never silently ignored. GET /v1/storage-buckets lists yours; DELETE /v1/storage-buckets/ revokes it (and revoking your principal disables all of them). To publish a bucket as your public website, see PUBLIC SITES below. 5. Optional: API services (credential-injecting upstreams) Register a service whose upstream is an external API. You supply the upstream base URL, the header(s) the secret goes into, and the secret itself — a static value, or OAuth client creds + a refresh token. The backbone SEALS it per-principal (Cloud KMS) and injects it into every authorized request, so callers never hold the raw secret. POST https://api.tktk.link/v1/services (client certificate) {"kind":"api","name":"stripe", "upstream":{ "base_url":"https://api.stripe.com", "headers":[{"name":"Authorization","value":"Bearer {{token}}"}], "auth":{"kind":"static","secrets":{"token":"sk_live_..."}}}} The header value is a template: {{token}} (or any secret name you define) is replaced with the sealed secret, so "Bearer {{token}}", "Custom {{token}}", or a bare "{{token}}" in any header all work. For OAuth, register client creds + a refresh token and the backbone refreshes on your behalf, injecting the reserved {{access_token}}: "auth":{"kind":"oauth","token_url":"https://.../oauth/token", "client_auth":"basic","client_id":"...","client_secret":"...", "refresh_token":"..."} client_auth is "basic" (default) or "body". You may also seed a live "access_token" with its "expires_at" (ISO 8601); an access_token sent WITHOUT expires_at is treated as never expiring and is never refreshed. A rotated refresh token is sealed back automatically. An API service IS a service: same ownership and grants as a reverse-proxy service. Reach it ONLY at its subdomain — there is no shared-domain path proxy. The secret is injected server-side and the response is relayed verbatim: https://.tktk.link/ (client certificate) GET /v1/services//upstream shows the secret-free config; PATCH the same path rotates it (owner-only). Share access with the usual POST /v1/services//grants. 6. Optional: collect a credential from a human (embedded setup) When a DEVICE is building an api service but does not have the credential yet, it can ask a human for it instead of holding a secret. The device posts a SPEC of what to collect — it does not create the service: POST https://api.tktk.link/v1/services/setup-requests (client certificate) {"name":"Stripe","logo_url":"https://.../logo.svg", "base_url":"https://api.stripe.com", "instructions":"Paste a restricted key.", "fields":[{"name":"token","label":"Secret API Key", "instructions":"Create one in the dashboard.", "help_url":"https://dashboard.stripe.com/apikeys"}]} Response: setup_url + user_code + poll_secret + poll_url. headers default to "Authorization: Bearer {{}}" for a single field; supply your own headers (same template as above) for anything else. OAuth works the same way: add an "auth" block and the backbone runs the refresh-token grant itself, injecting {{access_token}} (the default header is "Authorization: Bearer {{access_token}}"). The device PREFILLS whatever it already holds and the human supplies the rest — here the device has the client id, the current refresh token and a live access token, and only the human has the client secret: {"name":"QuickBooks","base_url":"https://quickbooks.api.intuit.com", "auth":{"kind":"oauth","client_auth":"basic", "token_url":"https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"}, "fields":[{"name":"client_id","label":"Client ID","secret":false}, {"name":"client_secret","label":"Client Secret"}], "prefill":{"client_id":"AB...","refresh_token":"RT1-...", "access_token":"eyJ...","expires_at":"2026-09-30T19:00:00Z"}} client_id, client_secret and refresh_token are each required: as a field the human fills in, a prefill, or both (the human's value wins; leaving it blank keeps the device's). access_token is optional and PREFILL-ONLY, and always comes with its expires_at; without one, the first request mints it. token_url must be https; client_auth is "basic" (default) or "body". Any other field is a static secret your headers may use beside {{access_token}}; the OAuth credentials themselves go only to token_url. There is no browser redirect (authorization-code) step: bring a refresh token. prefill works for static specs too; its keys must be declared fields (or, under oauth, the credential names above). A prefill for a field marked "secret":false is shown in the form for the human to check. Every other prefill is SEALED on arrival and never shown — the page only names it. Once you hand over a refresh token, stop using it yourself: the backbone owns the token chain from then on, and a provider that rotates refresh tokens can invalidate your copy at the first refresh. The device shows the user_code and opens (or hands off) the setup_url: https://tktk.link/setup/ ← a human fills this in The webview shows the logo, the labelled fields with their instructions and a "how to generate this" link (opens in a new tab), every host the credentials go to, and the user_code to confirm it matches the device. On submit, the backbone mints the api service owned by the REQUESTING device (ownership was fixed by the certificate that opened the request, never by who fills the form) and seals the collected values. Meanwhile the device long-polls: GET https://api.tktk.link/v1/services/setup-requests/ x-setup-request-secret: pending until a human finishes, then {"status":"completed","service":..., "url":...} exactly once. Nothing is created until setup completes. ── PUBLIC SITES (static hosting from a bucket) ───────────────────────────── Every user gets one public website, served to anyone from a bucket they own — the first piece of artifact hosting: https://.sites.tktk.link/ 1. Publish it. You must OWN the bucket (a grant on it is not enough) and hold the bucket:publish grant: PUT https://api.tktk.link/v1/site (client certificate) {"bucket": "", "path": "dist/"} Response: {"name": "brave-otter", "url": "https://brave-otter.sites.tktk.link/", "bucket": "", "path": "dist/", "live": true} path is optional (default: the whole bucket) and is the folder inside the bucket the site is served from; nothing outside it is reachable. The site belongs to your USER, not to this device: any of your devices manages the same one. The name is assigned, not chosen, and stays yours. PUT again to point it at another bucket or path. 2. Put files in the bucket with any S3 client (FOR CLIENTS, step 4), e.g. dist/index.html. They are served as soon as they are written. 3. How a URL finds its file (the first one that exists answers): / index.html, then index.htm /docs/ docs/index.html, then docs/index.htm else 302 to /docs, if docs.html exists /docs docs, then docs.html else 302 to /docs/, if docs/ has an index Anything else missing gets your 404.html with status 404, or a plain 404 if you have none. GET and HEAD only; Range and If-None-Match work; the query string is ignored. There is NEVER a directory or bucket listing. Content-Type is the one stored with the object, or a guess from its extension when none was stored. 4. Pages run SANDBOXED. Every response carries Content-Security-Policy: sandbox (without allow-same-origin), so a page gets no cookies, localStorage, IndexedDB or service workers. Scripts, forms, popups, modals and downloads work, and fetch() of your own files works (Access-Control-Allow-Origin: *). 5. Check it, or take it down: GET https://api.tktk.link/v1/site live: false means it is NOT being served (the bucket was deleted, or bucket:publish was revoked) DELETE https://api.tktk.link/v1/site unpublish; the name is kept for next time Errors: 401 no certificate; 403 your principal has no user owner, or bucket:publish required; 404 bucket not found (or not yours); 422 bad bucket id or path. On the site itself, x-tklink-error names our own errors (unknown_site, not_found, method_not_allowed, bad_gateway, sites_unavailable). ── QUICK REFERENCE ───────────────────────────────────────────────────────── Grouped by HOST, because the host is half of the address: the same path on the wrong one is a 421, not a redirect. Everything programmatic is under /v1 — that prefix is the compatibility promise, and it is the only place a breaking change is allowed to appear. https://provision.tktk.link POST /v1/principals/requests client cert register THIS connection's key (human approves) GET /v1/principals/requests/:id poll secret long-poll until the key is registered https://api.tktk.link GET /v1/devices client cert list your user's active registered devices (including self) POST /v1/services client cert create a service you own (tunnel, or kind=api) GET /v1/services client cert list services you own + can access POST /v1/services/setup-requests client cert start an embedded credential-collection flow (kind=api) GET /v1/services/setup-requests/:id setup secret long-poll until a human finishes; the service is delivered DELETE /v1/services/setup-requests/:id setup secret abandon a pending setup request PATCH /v1/services/:service client cert owner sets the service's name DELETE /v1/services/:service client cert delete a service you own GET /v1/services/:service/upstream client cert owner views api upstream config (secret-free) PATCH /v1/services/:service/upstream client cert owner replaces api upstream config / rotates creds POST /v1/services/:service/grants client cert owner grants a principal or user access DELETE /v1/services/:service/grants/:pid client cert owner revokes a principal grant DELETE /v1/services/:service/user-grants/:user_id client cert owner revokes shared user access POST /v1/access-requests client cert request access at your cert's scope; body declares kind "bucket"|"gateway" (returns approve_url) GET /v1/access-requests/:request_id client cert long-poll your own request until it is decided DELETE /v1/access-requests/:request_id client cert withdraw your own pending request POST /v1/storage-buckets client cert mint an S3 bucket GET /v1/storage-buckets client cert list your S3 buckets DELETE /v1/storage-buckets/:bucket client cert revoke an S3 bucket GET /v1/site client cert your user's public static site PUT /v1/site client cert publish a bucket you own at .sites (bucket:publish) DELETE /v1/site client cert take your site down (keeps the name) GET /v1/admin/services admin token list services GET /v1/admin/cluster admin token cluster node status POST /v1/admin/principals admin token register a public key directly GET /v1/admin/principals admin token list principals DELETE /v1/admin/principals/:pid admin token revoke a principal POST /v1/admin/grants admin token grant principal → service DELETE /v1/admin/grants/:pid/:service admin token revoke a grant GET /v1/admin/grants/:pid admin token list grants for principal GET /v1/admin/access-requests admin token list pending requests (?service= to filter) GET /v1/admin/access-requests/:id admin token show one pending access request POST /v1/admin/access-requests/:id/approve admin approve it (writes the grant; single-use) POST /v1/admin/access-requests/:id/deny admin deny it (no grant; cannot be approved after) POST /v1/admin/drain admin token begin draining this node GET /v1/admin/drain admin token drain status https://tktk.link GET / none this page GET /healthz none health check GET /login none on-device Apple/Google setup; setup=child requires a child profile GET /auth/:provider/start none start Apple/Google setup with device_id, spki_hash, optional app_redirect and setup=child GET /auth/:provider/callback OIDC state Google callback POST /auth/:provider/callback OIDC state Apple callback GET /auth/device/:setup_token setup token choose the device user; child mode omits the adult POST /auth/device/:setup_token setup token confirm device owner; child mode rejects adult selection POST /auth/device/:setup_token/children setup token create a child profile, then confirm setup POST /auth/exchange one-time code redeem setup code for the child's principal and user GET /console sign-in the browser console (humans) GET /console/users sign-in create child profiles and manage child devices POST /console/users sign-in create a managed child without email or login POST /console/users/:user_id/devices/:principal_id/revoke sign-in revoke a managed child's device GET /console/invites sign-in select services, labels, descriptions and grant level POST /console/invites sign-in owner creates a single-use invitation (seven days) POST /console/invites/:invite_id/revoke sign-in owner revokes an unused invitation GET /invites/ none review services and choose your account POST /invites//account sign-in confirm account; user-level invitations grant access here POST /invites//switch-account none sign in to another account and return to the invite POST /invites//accept sign-in grant the bundle to your selected principal GET /access/:request_id none an access request: status for anyone, decision for the service owner POST /access/:request_id[/deny] sign-in decide it (service owner only) GET /setup/:request_id user_code the embedded webview a human fills in to finish a service ──────────────────────────────────────────────────────────────────────────── Typed API schemas and MCP tool definitions: https://tktk.link/api.json Refresh with If-None-Match; rebuild tools when the document changes. JSON? Send Accept: application/json