# Write a cookie adapter

> The ~50-line pattern for a new sink adapter: `Register()` into the adapter registry, the validate hook, the seal helper, and the five built-in PP-CLI adapters as templates.

- Repository: mvanhorn/agentcookie
- GitHub: https://github.com/mvanhorn/agentcookie
- Human docs: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae
- Complete Markdown: https://grok-wiki.com/public/docs/mvanhorn-agentcookie-137da38edfae/llms-full.txt

## Source Files

- `docs/runbook-v0.11-adapter-cookie-push.md`
- `internal/sinkpush/adapter.go`
- `internal/sinkpush/registry.go`
- `internal/sinkpush/adapter_instacart.go`
- `internal/sinkpush/adapter_airbnb.go`
- `internal/sinkpush/adapter_tablereservation.go`

---

---
title: "Write a cookie adapter"
description: "The ~50-line pattern for a new sink adapter: `Register()` into the adapter registry, the validate hook, the seal helper, and the five built-in PP-CLI adapters as templates."
---

A sink-side cookie adapter is a Go type that implements `sinkpush.Adapter` (five methods), registers itself from a package `init()` function, and writes the filtered subset of decrypted Chrome cookies into one target CLI's local session cache. After each cookie sync the sink calls `sinkpush.RunAll`, which iterates the package registry, filters cookies per adapter, runs the central `Validate` gate, optionally seals secret-bearing fields through `maybeSeal`, and routes errors into per-adapter `Result` entries without aborting the sync. The five built-ins under `internal/sinkpush/` (`adapter_instacart.go`, `adapter_airbnb.go`, `adapter_ebay.go`, `adapter_pagliacci.go`, `adapter_tablereservation.go`) cover the two stable templates — auth-paste stdin and pycookiecheat-style TOML+JSON — plus one bespoke single-file session JSON.

## The Adapter interface

Every adapter satisfies one interface defined in `internal/sinkpush/adapter.go`. Five methods, no lifecycle, no constructor contract:

```go
type Adapter interface {
    Name() string
    CLIBinary() string
    IsInstalled() bool
    CookieHostPatterns() []string
    Push(cookies []chrome.Cookie) error
}
```

<ParamField body="Name" type="string" required>
Unique identifier used in sink logs, `Result.Name`, and `wizard verify-adapters` output. By convention the target CLI's binary name (e.g. `"instacart-pp-cli"`).
</ParamField>

<ParamField body="CLIBinary" type="string" required>
Absolute path to the target CLI on disk. Used by `IsInstalled` and, for auth-paste adapters, as the exec target.
</ParamField>

<ParamField body="IsInstalled" type="bool" required>
Reports whether the binary exists. `RunAll` short-circuits to `Skipped: true, SkippedReason: "CLI not installed"` when this returns false — a missing CLI is never an error.
</ParamField>

<ParamField body="CookieHostPatterns" type="[]string" required>
SQLite-LIKE patterns matched against `chrome.Cookie.HostKey` before `Push` is called. Returning `nil` or `[]string{}` means "give me every cookie" — used only by greedy adapters. A single `"%vendor%"` pattern is the norm.
</ParamField>

<ParamField body="Push" type="func([]chrome.Cookie) error" required>
Writes the filtered + validated cookies into the CLI's session cache. Receives only cookies that passed `Validate`. Errors land in `Result.Err`; one adapter's failure does not stop subsequent adapters.
</ParamField>

The runtime input shape adapters receive is `chrome.Cookie` from `internal/chrome/read.go`:

```go
type Cookie struct {
    HostKey       string  // ".instacart.com"
    Name          string
    Value         string
    Path          string
    ExpiresUTC    int64   // microseconds since 1601-01-01 (WebKit epoch)
    IsSecure      int
    IsHTTPOnly    int
    LastAccessUTC int64
    HasExpires    int
    IsPersistent  int
    Priority      int
    SameSite      int
    SourceScheme  int
    SourcePort    int
}
```

## Registry, lifecycle, and execution order

The registry is a package-level slice in `internal/sinkpush/registry.go`. `Register` is append-only — no `Deregister` exists by design — so adapters run in the order they were registered. `internal/sinkpush/init.go` registers the five built-ins at package load:

```go
func init() {
    Register(NewInstacart())
    Register(NewAirbnb())
    Register(NewEbay())
    Register(NewPagliacci())
    Register(NewTableReservation())
}
```

`RunAll(cookies)` walks the registry and per adapter:

1. Calls `IsInstalled()`. If false → `Skipped: true, SkippedReason: "CLI not installed"`.
2. Calls `filterByHostPatterns(cookies, a.CookieHostPatterns())`. If the filtered slice is empty → `Skipped: true, SkippedReason: "no matching cookies"`.
3. Runs `Validate` on each filtered cookie. Failures bump `Result.Invalid` and are dropped; if every cookie fails → `Skipped: true, SkippedReason: "all cookies failed validation"`.
4. Calls `Push(valid)`. The error (if any) lands in `Result.Err`; success sets `Result.Pushed = len(valid)`.

```mermaid
flowchart TB
    subgraph Sink["sink LaunchAgent process"]
        Cookies["[]chrome.Cookie<br/>(decrypted from sync)"]
        RunAll["sinkpush.RunAll"]
    end

    subgraph Registry["registry (registration order)"]
        A1["NewInstacart()"]
        A2["NewAirbnb()"]
        A3["NewEbay()"]
        A4["NewPagliacci()"]
        A5["NewTableReservation()"]
    end

    subgraph PerAdapter["per-adapter pipeline (runOne)"]
        Installed{"IsInstalled?"}
        Filter["filterByHostPatterns<br/>(SQLite LIKE)"]
        Val["Validate<br/>name / value / host_key"]
        Seal["maybeSeal<br/>(agc1: envelope when<br/>master key present)"]
        Push["Push() writes session file"]
    end

    Cookies --> RunAll
    RunAll --> Registry
    Registry --> Installed
    Installed -- yes --> Filter
    Filter -- non-empty --> Val
    Val --> Seal
    Seal --> Push
    Push --> Result["Result{Name, Pushed, Skipped, Invalid, Err}"]
```

## Validate hook

`sinkpush.Validate` (in `validate.go`) is the single gate every cookie crosses before reaching an adapter's `Push`. It exists because adapters write `Name=Value` pairs into TOML, JSON, and stdin streams whose parsers can be confused or hijacked by control characters or quoting.

| Field | Rule | Rejection reason |
|---|---|---|
| `Name` | RFC 6265 token chars only: `0x21..0x7E` minus `( ) < > @ , ; : \ " / [ ] ? = { }` | `errEmptyName`, `errNameTokenChars` |
| `Value` | Any byte ≥ `0x20` except `0x7F` (DEL) | `errValueControl` |
| `HostKey` | Non-empty, no control chars, no `..`, `/`, `\` | `errHostKeyEmpty`, `errHostKeyControl`, `errHostKeyTraverse` |

Adapters do not call `Validate` themselves — `runOne` does it. A failing cookie increments `Result.Invalid` and is dropped; the adapter still runs on the remaining valid cookies.

The same file exports one helper adapters do call: `HostSuffixMatch(hostKey, suffix)`. Use it to bin cookies between sibling services without the "string `HasSuffix` matches unrelated host" bug — the character before the suffix must be a `.` label boundary.

## Seal helper

`internal/sinkpush/seal.go` exposes `maybeSeal(plain string) (string, error)`. Adapters call it on every secret-bearing field they write to disk:

```go
sealed, err := maybeSeal(headerOrCookieValue)
if err != nil {
    return fmt.Errorf("seal: %w", err)
}
```

Behavior:

- When `keystore.MasterKeyExists()` is false → returns `plain, nil`. v0.11 plaintext shape is preserved so partial installs work.
- When the master key is in the macOS Keychain → returns `SealedPrefix + base64(seal(masterKey, plaintext))`, where `SealedPrefix = "agc1:"`.
- Only returns an error when the master key item is present but unreadable, or `keystore.Seal` itself fails.

Downstream PP CLIs detect the `agc1:` prefix and unseal via `pkg/sidecar`'s keystore. Adapters do not seal `HostKey`, `Path`, or `Name` — only the secret-carrying field per format (the Cookie-header string for pycookiecheat-style, each cookie `Value` for the table-reservation session JSON).

## Built-in templates

The five built-ins are three patterns. Pick the closest one and copy it.

### Template 1: auth-paste stdin (instacart)

`adapter_instacart.go` shells out to `<cli> auth paste` with a `Cookie:` header value on stdin. The CLI parses and writes its own session.json. Use this template when the target CLI ships an `auth paste` (or equivalent) import subcommand.

```go
type InstacartAdapter struct { binary string }

func NewInstacart() *InstacartAdapter {
    home, _ := os.UserHomeDir()
    return &InstacartAdapter{binary: filepath.Join(home, "go", "bin", "instacart-pp-cli")}
}

func (a *InstacartAdapter) Name() string              { return "instacart-pp-cli" }
func (a *InstacartAdapter) CLIBinary() string         { return a.binary }
func (a *InstacartAdapter) IsInstalled() bool         { fi, err := os.Stat(a.binary); return err == nil && !fi.IsDir() }
func (a *InstacartAdapter) CookieHostPatterns() []string { return []string{"%instacart%"} }

func (a *InstacartAdapter) Push(cookies []chrome.Cookie) error {
    header := formatCookieHeader(cookies)
    if header == "" { return nil }
    cmd := exec.Command(a.binary, "auth", "paste")
    cmd.Stdin = strings.NewReader(header)
    var stderr bytes.Buffer
    cmd.Stderr = &stderr
    if err := cmd.Run(); err != nil {
        return fmt.Errorf("instacart-pp-cli auth paste: %w (stderr: %s)", err, strings.TrimSpace(stderr.String()))
    }
    return nil
}
```

`formatCookieHeader` (also in the file) drops cookies whose `Value` is empty — those carry no auth and the CLI parser would treat them as deletes that clobber existing state.

### Template 2: pycookiecheat-style config.toml + cookies.json (airbnb, ebay, pagliacci)

Three of the five built-ins share `PycookiecheatStyleAdapter` (in `adapter_pycookiecheat.go`). The concrete constructors are trivial — `adapter_airbnb.go` is the canonical example:

```go
func NewAirbnb() *PycookiecheatStyleAdapter {
    return newPycookiecheatStyleAdapter(
        "airbnb-pp-cli",          // Name + binary basename in ~/go/bin
        "%airbnb%",               // single SQLite LIKE pattern
        "airbnb-pp-cli",          // ~/.config/<configDir>/
        "https://www.airbnb.com", // base_url written into a fresh config.toml
    )
}
```

`adapter_ebay.go` and `adapter_pagliacci.go` are the same six lines with different vendor strings. The shared implementation handles:

- Mode-0700 `MkdirAll` on `~/.config/<cli>/`.
- Atomic write of `config.toml`. If the file already exists, only the `access_token = '...'` line is rewritten via the `accessTokenLine` regex — user-set `base_url` and other fields are preserved. If the file is fresh, `freshConfigTOML` writes the canonical layout (`base_url`, `auth_header`, `access_token`, `refresh_token`, `token_expiry`, `client_id`, `client_secret`).
- Atomic write of `cookies.json` with the shape `{"cookies": "<cookie header>"}`.
- `maybeSeal` on the entire cookie-header string before either write, so both files carry the same `agc1:`-prefixed payload when the master key is present.
- `escapeTOMLSingleQuoted` strips embedded `'` (rare in cookie values; Chrome never emits them) so the literal-string TOML form stays valid.
- `atomicWriteFile` writes to `<path>.agentcookie.tmp` then renames, so readers never see a half-written file.

### Template 3: bespoke single-file session JSON (table-reservation)

When the target CLI has neither an `auth paste` import nor a pycookiecheat-shaped config, model on `adapter_tablereservation.go`. It demonstrates the cleanest one-off pattern:

- Two host patterns in `CookieHostPatterns`: `"%opentable.com"` and `"%exploretock.com"`. Two networks share one session file.
- `chromeToSessionCookie` converts each `chrome.Cookie` into the per-cookie JSON shape (`name`, `value`, `domain`, `path`, `expires`). `chromeExpiresToRFC3339` translates `ExpiresUTC` (microseconds since the WebKit epoch) into RFC3339Nano, mapping the session-cookie sentinel `0` to a far-future date so the CLI's required-`expires` invariant is preserved. The WebKit-to-Unix delta is the local constant `chromeEpochDeltaMicros`.
- `HostSuffixMatch(c.HostKey, "opentable.com")` and `"exploretock.com"` bin cookies into `opentable_cookies` and `tock_cookies` arrays inside the `sessionEnvelope` (`version: 1`, `updated_at` RFC3339Nano).
- `maybeSeal` is applied to each individual `sessionCookie.Value`, not the envelope.
- Empty-value cookies are dropped.
- Final write is atomic, mode `0600`, into `~/.config/table-reservation-goat-pp-cli/session.json`.

## End-to-end recipe for a new adapter

<Steps>
<Step title="Inspect the target CLI's session-file shape">
On a Mac where the target CLI has been authenticated through its own native flow (e.g. `<cli> auth login --chrome`), capture the files it writes: usually under `~/.config/<cli>/`. Note the field names, encoding, whether multiple files share the same header, and which fields are secret-bearing. The three patterns above cover most PP CLIs; deviations imply Template 3.
</Step>

<Step title="Add internal/sinkpush/adapter_<name>.go">
For pycookiecheat-style targets, a six-line constructor wrapping `newPycookiecheatStyleAdapter` is enough. For auth-paste, copy `adapter_instacart.go` and change the binary name, `Name`, host pattern, and exec args. For a bespoke session file, copy `adapter_tablereservation.go` and replace the `sessionCookie` / `sessionEnvelope` types with the discovered schema. Keep the new file under ~50 lines whenever the pattern allows.
</Step>

<Step title="Wire into init()">
Append one `Register(NewYourAdapter())` line to `internal/sinkpush/init.go`. Adapters run in registration order — there is no priority field.
</Step>

<Step title="Write a unit test against a temp config dir">
Adapters that write files should be testable by constructing the struct directly (bypassing `New*`) so `configDir` and `binary` point at a `t.TempDir()`. See `adapter_instacart_test.go`, `adapter_pycookiecheat_test.go`, and `adapter_tablereservation_test.go` for the established patterns: feed synthetic `chrome.Cookie` slices to `Push`, assert files on disk, and round-trip the sealed envelope when the master key is installed.
</Step>

<Step title="Verify on a real sink">
Rebuild and redeploy the binary, then trigger a sync and inspect:

```bash
ssh sink-mac '/Users/<user>/bin/agentcookie wizard verify-adapters'
```

Expect a row showing `ok` and a non-zero `PUSHED` count. Confirm the CLI itself runs without prompts:

```bash
ssh sink-mac '/Users/<user>/go/bin/<your-cli> doctor'
```
</Step>
</Steps>

## Per-adapter behavior cheatsheet

| Adapter | Host pattern(s) | Target file(s) | Push strategy |
|---|---|---|---|
| `instacart-pp-cli` | `%instacart%` | written by CLI itself | exec `auth paste`, header on stdin |
| `airbnb-pp-cli` | `%airbnb%` | `~/.config/airbnb-pp-cli/config.toml` + `cookies.json` | `PycookiecheatStyleAdapter` |
| `ebay-pp-cli` | `%ebay%` | `~/.config/ebay-pp-cli/config.toml` + `cookies.json` | `PycookiecheatStyleAdapter` |
| `pagliacci-pp-cli` | `%pagliacci%` | `~/.config/pagliacci-pp-cli/config.toml` + `cookies.json` | `PycookiecheatStyleAdapter` |
| `table-reservation-goat-pp-cli` | `%opentable.com`, `%exploretock.com` | `~/.config/table-reservation-goat-pp-cli/session.json` | bespoke envelope, per-value seal |

## Result shape and skip semantics

`Push` is the only point where an adapter can fail meaningfully. Everything else routes into `Result` fields without returning an error:

<ResponseField name="Name" type="string">
The adapter's `Name()` — always populated.
</ResponseField>

<ResponseField name="Skipped" type="bool">
True for benign no-ops: CLI not installed, host filter empty, all cookies invalid.
</ResponseField>

<ResponseField name="SkippedReason" type="string">
`"CLI not installed"`, `"no matching cookies"`, or `"all cookies failed validation"`.
</ResponseField>

<ResponseField name="Pushed" type="int">
Count of validated cookies handed to `Push`. Zero on skip or error.
</ResponseField>

<ResponseField name="Invalid" type="int">
Count of cookies dropped by `Validate` before `Push`. Non-zero is interesting (a source pushing garbage) but does not itself fail the adapter.
</ResponseField>

<ResponseField name="Err" type="error">
Non-nil only when `Push` returned an error. `Result.OK()` is true for success and skip; false only here.
</ResponseField>

## Design constraints to respect

<Warning>
Do not call `Validate`, `filterByHostPatterns`, or maintain your own host filter inside `Push`. The runtime does both before `Push` is reached, and a second pass risks divergence between `Result.Pushed` and the adapter's actual write count.
</Warning>

<Warning>
Do not panic from `Push`. `runOne` does not recover. A panic in one adapter aborts the rest of the sweep for that sync cycle.
</Warning>

<Tip>
Write file outputs atomically. Every built-in uses the `atomicWriteFile(path, body, 0o600)` helper in `adapter_pycookiecheat.go` — temp file plus rename, mode 0600. Concurrent reads by the target CLI must see either the prior state or the new state, never a half-written intermediate.
</Tip>

<Tip>
Empty-value cookies should be dropped, not written. The pycookiecheat-style header builder and the table-reservation per-cookie loop both skip `c.Value == ""` — those cookies carry no auth signal and downstream parsers treat the empty value as a delete that clobbers good session state.
</Tip>

<Note>
The user-facing YAML schema for runtime-registered adapters in `~/.config/agentcookie/sink.yaml` is not yet shipped — new adapters live in the agentcookie source tree and require a rebuild + redeploy of the sink LaunchAgent binary.
</Note>

## Related pages

<CardGroup cols={2}>
  <Card title="Cookie delivery surfaces" href="/cookie-delivery-surfaces">
    Where adapter outputs fit alongside Chrome Safe Storage and the plaintext sidecar.
  </Card>
  <Card title="Universal cookie delivery" href="/universal-delivery">
    The unmodified-Chrome read path that adapters complement.
  </Card>
  <Card title="doctor and adapter verification" href="/doctor-health-checks">
    `wizard verify-adapters`, the `DoctorReport` envelope, and exit codes for agent consumption.
  </Card>
  <Card title="v0.11 adapter cookie-push runbook" href="/troubleshooting">
    Common adapter failure modes and recovery steps after a sync.
  </Card>
</CardGroup>
