An agent-facing Gmail and Google Calendar CLI. A single Go binary exposes mail and calendar as composable subcommands with structured output, designed to be driven by an LLM agent on a headless server — not clicked by a human in a terminal.
docket <auth|mail|cal> <subcommand> [flags]
The full design rationale, known limitations, and operator controls live in
docket-design.md. This README covers what it does and how to run it.
An agent tool needs to be scriptable end-to-end: no interactive prompts, stable IDs
instead of positions, cheap reads and explicit writes, and errors that tell the model
exactly what to call next. docket bakes those rules into the binary so no external
policy layer has to.
Design principles (see docket-design.md §1):
mail read/cal show address messages and events by
their stable Gmail/Calendar IDs, not by index or sequence number.--confirm and supports --dry-run.retryable flag, and messages
written for the model caller — they say what was wrong with this invocation and what a
corrected call looks like.# Nix (devshell with Go toolchain, gopls, golangci-lint)
nix develop
# or, plain Go
go build -o docket ./cmd/docket
# Makefile targets: build, format, test, vet
make build
Auth is configuration, not code: the OAuth provider lives in a config file, so the
provider's borrowed client (config/config.toml ships in-repo) can be swapped for your own
registered client without touching code.
# 1. Provision config (already in repo — copy or point XDG_CONFIG_HOME at it)
mkdir -p ~/.config/docket
cp config/config.toml ~/.config/docket/config.toml
# 2. Login. On a headless box, docket prints the SSH tunnel to run locally:
docket auth login
# ssh -L 8080:localhost:8080 server # then open the printed URL in a browser
Login runs a loopback listener and completes the PKCE/OAuth flow; the token is stored at
$XDG_STATE_HOME/docket/token.json (0600). You can also run the flow on a laptop and pipe
the token to the server:
docket auth export > token.json # from the laptop
cat token.json | docket auth import # on the server
Verify with docket auth whoami.
Warning: the borrowed OAuth client is Mozilla Thunderbird's — it can vanish when Thunderbird moves to dynamic client registration, and your Google security page will attribute docket's access to Mozilla Thunderbird. For anything beyond personal use, register your own client and point
config.tomlat it (seedocket-design.md§8).
docket auth| Subcommand | Purpose |
|---|---|
login | Start OAuth login (prints tunnel + URL) |
whoami | Resolve the authenticated account address |
export / import | Pipe a token between machines (refresh tokens aren't machine-bound) |
docket mail — Gmail (REST API)| Subcommand | Purpose |
|---|---|
mail search --query "..." | Native Gmail query syntax (from:emiel is:unread) |
mail list --label INBOX [--unread] | List messages in a label |
mail read --id <gm-msgid> | Full body; prefers text/plain, truncates at --max-bytes (0 = no cap); --html adds the raw text/html part |
mail thread --id <gm-thrid> | Read a whole conversation (envelopes only unless --html) |
mail attachment --id <gm-msgid> --part <part-id> --out <path> | Write one attachment's bytes to a file |
mail send --to ... --subject ... --body-file - | Send (mutating: --confirm) |
mail reply --id <gm-msgid> --body-file - | Reply (mutating: --confirm); --reply-all answers everyone the message was addressed to |
mail label --id <gm-msgid> --add Foo --remove INBOX | Apply/remove labels (mutating: --confirm) |
The CLI's send/reply build a text/plain message; the library they call (gmail/mail)
takes a Body — the text and, when a caller has one, the same words as HTML — and writes the
second as a part of the same multipart/alternative. See
docket-design.md.
search/list return envelopes only (id, thread id, from/to, subject, date, labels,
snippet) — bodies are expensive, so callers ask for them explicitly with read.
Notes:
--limit is the size of one page, defaulting to 25 and capped at 500 (Gmail's own
ceiling for a single messages.list call). Asking for more is a usage error rather than a
silent clamp, because 500 results out of 3000 are indistinguishable from a complete answer.page.next_page_token from the
envelope back as --page-token. page.has_more is the short answer to "did I get
everything"; see the output contract below.mail read --max-bytes 0 returns the whole body. Truncation cuts from the end, which
in a forwarded trail is the oldest quoted material, so the cap is worth turning off
whenever the point of the read is history rather than the latest reply. truncated: true
says a cap was applied.--html on read adds two fields: body_html, the text/html part exactly as sent,
and html_status, which is present or none. none means the message genuinely has no
html part — a text-only message is normal mail, and a caller has to be able to tell that
from a flag that did nothing, which is why the field is absent altogether without --html.
Nothing is sanitised: strip what you will not render at render time, on the reasoning that
you can inspect your own sanitiser and cannot inspect ours.--html returns: the first text/html part in the MIME tree, and body stays
the first text/plain part. For the usual multipart/alternative that is the two
representations of the same message. Nesting does not change the choice — a
multipart/mixed wrapping an alternative resolves to the same pair. In a
multipart/related, the html part is returned and the image/* parts it references are
not: inline images appear under attachments when they carry a filename, and a cid: URL
in the markup is resolved by matching it to that entry's content_id and fetching the part
with mail attachment. A message with only a text/html part still gets a body, its markup rendered as text.--html on thread returns every message's body and body_html rather than envelopes
alone, from the same single API call, and honours --max-bytes per body. It is opt-in
because a conversation's worth of bodies is the largest response docket produces. The
text body comes along with the markup because a text-only reply in the middle of a thread
has nothing else to render.html_truncated: true says the markup you hold is incomplete. It is separate from
truncated, which is about body: the cap applies to each body on its own, and an html
part is routinely an order of magnitude larger than the text beside it, so one flag for
both would report a whole text body as cut. Truncated markup is cut back to sit after the
last complete tag and character reference, so it parses — a cut left mid-tag does not, a
parser swallows the remainder of the document into an attribute value. Elements left
unclosed are fine; every html parser closes those implicitly.mail attachment writes the bytes to --out and returns the path, size,
mime type, filename and a sha256 of the content in the envelope. The bytes do not go to
stdout: stdout carries the JSON envelope, and a caller that has to locate the envelope
inside a stream of PNG bytes cannot read a failure at all — which is the one thing it must
never lose. Nothing is resized, re-encoded or sniffed; the file holds what the sender sent.ok cannot express — a call where three of five
parts are gone is neither a success nor a failure.0600, so an interrupted fetch leaves no short file
for a later pass to mistake for a complete one, and a corpus of private mail is not left
world-readable by whatever umask happened to be in force. An existing file at --out is
overwritten: a resumed backfill re-fetches what it already has.--max-bytes on attachment means the same as everywhere else, 0 included, and defaults
to 10 MB. Over the cap is ATTACHMENT_TOO_LARGE, not a truncated file — half a PNG is not
a smaller PNG, and once written to disk it is indistinguishable from a whole one. The cap
is checked against the size in the message metadata before the content call, so an
oversized attachment costs one cheap request rather than a download; it is checked again
against the bytes received, because the metadata size is Gmail's claim and not a
measurement. mail read reports every attachment's size, so a caller can skip one
without asking for it at all; the cap is there for the accident.MESSAGE_NOT_FOUND (exit 4, the message is
gone), PART_NOT_FOUND (exit 4, the message is there and has no such part — the error
names the part ids it does have), ATTACHMENT_UNAVAILABLE (exit 4, the part is there with
no content behind it), and OUTPUT_WRITE_FAILED (exit 1, the bytes arrived and the local
write failed — nothing about the mailbox is wrong). A rate limit stays RATE_LIMITED,
exit 5, retryable: true: reported as a missing part it would be recorded as a permanent
gap and never asked for again.attachments entries carry content_id for the parts an html body references by
cid:, with the angle brackets stripped so it matches the URL token directly. Without it
a consumer holding cid:ii-9f3c2a@mail.example.com has no way to say which part id that
is, and an inline screenshot — content, not decoration — stays a broken image.--verbose adds the threading/cc columns to the terminal table. JSON output always
contains every field, so it does nothing for a programmatic caller. --all is a
deprecated alias.docket cal — Google Calendar (CalDAV + client-side RRULE expansion)| Subcommand | Purpose |
|---|---|
cal agenda [--days 7] | Upcoming events |
cal show --id <event-id> | One event (id form <uid>::<RFC3339 start>) |
cal freebusy --start ... --end ... | Busy ranges in a window |
cal find-slot --duration 45m --within 5d --hours 09:00-17:00 | Find free time — the highest-value command |
cal create --summary ... --start ... --duration ... | Create (mutating: --confirm) |
cal update --id <event-id> ... | Update (mutating: --confirm) |
cal delete --id <event-id> | Delete (mutating: --confirm) |
Notes:
--tz is required on every time-parsing command (IANA zone, e.g.
Australia/Melbourne). Google's CalDAV interface can't cheaply report a calendar's
configured timezone, and guessing causes silent off-by-hours bugs.cal create accepts --rrule as a raw RFC 5545 value ("FREQ=WEEKLY;BYDAY=MO,WE,FR"),
plus optional --attendees, --location, and --idempotency-key (reusing a key on retry
prevents duplicate events).--calendar defaults to the account's primary calendar for
reads, and to default_calendar in config.toml for writes. agenda/freebusy/
find-slot accept --calendar all to merge every calendar (so busy slots respect
holidays/shared/work calendars).agenda/freebusy for minutes via the legacy
CalDAV time-range query — trust create's own return value (or cal show on the returned
id) rather than immediately re-querying agenda to confirm.cal update --location "" can't blank a field (empty == "not provided").Every command emits one JSON envelope on stdout:
{ "ok": true, "data": { }, "warnings": [], "error": null }
search/list add a page object describing what you are holding:
{ "ok": true, "data": [ ],
"page": { "returned": 500, "limit": 500, "has_more": true, "next_page_token": "09vv…" },
"error": null }
{ "ok": false, "data": null,
"error": { "code": "AUTH_EXPIRED", "message": "refresh token rejected", "retryable": true } }
Two invariants hold for every command, enforced where the envelope is serialised:
ok: false always carries a non-null error with a populated code, message and
retryable, and always exits non-zero. A caller reading ok and then error.message
never has to handle a null.retryable: true is claimed only for causes known to be transient — rate limits, 5xx,
network timeouts, a refresh worth reattempting. A usage error, a missing message, or an
unrecognised failure reports false, so a client that backs off on retryable never
loops on something that cannot succeed.Mail error codes a caller may branch on: RATE_LIMITED, AUTH_EXPIRED, AUTH_REVOKED,
PERMISSION_DENIED, MESSAGE_NOT_FOUND / THREAD_NOT_FOUND, GMAIL_SERVER_ERROR,
NETWORK_ERROR, TIMEOUT, USAGE_ERROR, SEND_FAILED, CONFIRM_REQUIRED,
WRITES_DISABLED, GMAIL_API_ERROR (unclassified).
Exit codes (an agent's control flow is driven by these, not by parsing prose):
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Everything else |
| 2 | Usage error — malformed invocation (message names the fix) |
| 3 | Auth required / expired — needs a human |
| 4 | Not found |
| 5 | Rate limited or transient — safe to retry with backoff |
| 6 | Refused: mutating command without --confirm, or writes disabled |
Code 6 is the interesting one: it lets an agent propose a write, get a structured refusal describing exactly what would have happened, and surface that for approval — rather than acting unilaterally.
Writes can be shut off (or scoped) without touching agent-facing flags or redeploying —
handy for a systemd unit's Environment=. Any non-empty value counts as set.
| Variable | Effect |
|---|---|
DOCKET_READONLY | Disables all mail and calendar writes |
DOCKET_MAIL_READONLY | Disables mail send/reply/label only |
DOCKET_CAL_READONLY | Disables cal create/update/delete only |
DOCKET_CAL_OWN_EVENTS_ONLY | cal update/delete refuse anything docket didn't create (via a [docket] marker in the event description) — a middle tier between read-only and full write |
These combine into three deployment tiers: fully open, own-events-only, fully closed.
A refusal here is WRITES_DISABLED / NOT_OWNED, exit code 6 — refused, not failed.
cmd/docket/main.go thin CLI shell over the gmail library
gmail/ reusable library module (github.com/zachpmanson/docket/gmail)
config/ default config.toml embedded into the auth package
auth/ provider config, PKCE login flow, flock'd token store
mail/ Gmail REST v1 wrapper, MIME part walking, labels
cal/ CalDAV client, RRULE expansion, derived free/busy, find-slot
errors/ shared process exit codes
internal/
out/ result envelope, TTY detection
flake.nix devshell: Go toolchain, gopls, golangci-lint
docket-design.md design rationale, known limitations, build history
For the gory details — why CalDAV over the REST API, the client-side RRULE expansion gaps,
the flock-guarded token refresh, the write-safety gate, and known risks (the borrowed
client, PKCE/redirect drift, blast radius) — read docket-design.md.
It's the source of truth; this README is the elevator pitch.
Go
99.4%
An agent-facing Gmail and Google Calendar CLI. A single Go binary exposes mail and calendar as composable subcommands with structured output, designed to be driven by an LLM agent on a headless server — not clicked by a human in a terminal.
docket <auth|mail|cal> <subcommand> [flags]
The full design rationale, known limitations, and operator controls live in
docket-design.md. This README covers what it does and how to run it.
An agent tool needs to be scriptable end-to-end: no interactive prompts, stable IDs
instead of positions, cheap reads and explicit writes, and errors that tell the model
exactly what to call next. docket bakes those rules into the binary so no external
policy layer has to.
Design principles (see docket-design.md §1):
mail read/cal show address messages and events by
their stable Gmail/Calendar IDs, not by index or sequence number.--confirm and supports --dry-run.retryable flag, and messages
written for the model caller — they say what was wrong with this invocation and what a
corrected call looks like.# Nix (devshell with Go toolchain, gopls, golangci-lint)
nix develop
# or, plain Go
go build -o docket ./cmd/docket
# Makefile targets: build, format, test, vet
make build
Auth is configuration, not code: the OAuth provider lives in a config file, so the
provider's borrowed client (config/config.toml ships in-repo) can be swapped for your own
registered client without touching code.
# 1. Provision config (already in repo — copy or point XDG_CONFIG_HOME at it)
mkdir -p ~/.config/docket
cp config/config.toml ~/.config/docket/config.toml
# 2. Login. On a headless box, docket prints the SSH tunnel to run locally:
docket auth login
# ssh -L 8080:localhost:8080 server # then open the printed URL in a browser
Login runs a loopback listener and completes the PKCE/OAuth flow; the token is stored at
$XDG_STATE_HOME/docket/token.json (0600). You can also run the flow on a laptop and pipe
the token to the server:
docket auth export > token.json # from the laptop
cat token.json | docket auth import # on the server
Verify with docket auth whoami.
Warning: the borrowed OAuth client is Mozilla Thunderbird's — it can vanish when Thunderbird moves to dynamic client registration, and your Google security page will attribute docket's access to Mozilla Thunderbird. For anything beyond personal use, register your own client and point
config.tomlat it (seedocket-design.md§8).
docket auth| Subcommand | Purpose |
|---|---|
login | Start OAuth login (prints tunnel + URL) |
whoami | Resolve the authenticated account address |
export / import | Pipe a token between machines (refresh tokens aren't machine-bound) |
docket mail — Gmail (REST API)| Subcommand | Purpose |
|---|---|
mail search --query "..." | Native Gmail query syntax (from:emiel is:unread) |
mail list --label INBOX [--unread] | List messages in a label |
mail read --id <gm-msgid> | Full body; prefers text/plain, truncates at --max-bytes (0 = no cap); --html adds the raw text/html part |
mail thread --id <gm-thrid> | Read a whole conversation (envelopes only unless --html) |
mail attachment --id <gm-msgid> --part <part-id> --out <path> | Write one attachment's bytes to a file |
mail send --to ... --subject ... --body-file - | Send (mutating: --confirm) |
mail reply --id <gm-msgid> --body-file - | Reply (mutating: --confirm); --reply-all answers everyone the message was addressed to |
mail label --id <gm-msgid> --add Foo --remove INBOX | Apply/remove labels (mutating: --confirm) |
The CLI's send/reply build a text/plain message; the library they call (gmail/mail)
takes a Body — the text and, when a caller has one, the same words as HTML — and writes the
second as a part of the same multipart/alternative. See
docket-design.md.
search/list return envelopes only (id, thread id, from/to, subject, date, labels,
snippet) — bodies are expensive, so callers ask for them explicitly with read.
Notes:
--limit is the size of one page, defaulting to 25 and capped at 500 (Gmail's own
ceiling for a single messages.list call). Asking for more is a usage error rather than a
silent clamp, because 500 results out of 3000 are indistinguishable from a complete answer.page.next_page_token from the
envelope back as --page-token. page.has_more is the short answer to "did I get
everything"; see the output contract below.mail read --max-bytes 0 returns the whole body. Truncation cuts from the end, which
in a forwarded trail is the oldest quoted material, so the cap is worth turning off
whenever the point of the read is history rather than the latest reply. truncated: true
says a cap was applied.--html on read adds two fields: body_html, the text/html part exactly as sent,
and html_status, which is present or none. none means the message genuinely has no
html part — a text-only message is normal mail, and a caller has to be able to tell that
from a flag that did nothing, which is why the field is absent altogether without --html.
Nothing is sanitised: strip what you will not render at render time, on the reasoning that
you can inspect your own sanitiser and cannot inspect ours.--html returns: the first text/html part in the MIME tree, and body stays
the first text/plain part. For the usual multipart/alternative that is the two
representations of the same message. Nesting does not change the choice — a
multipart/mixed wrapping an alternative resolves to the same pair. In a
multipart/related, the html part is returned and the image/* parts it references are
not: inline images appear under attachments when they carry a filename, and a cid: URL
in the markup is resolved by matching it to that entry's content_id and fetching the part
with mail attachment. A message with only a text/html part still gets a body, its markup rendered as text.--html on thread returns every message's body and body_html rather than envelopes
alone, from the same single API call, and honours --max-bytes per body. It is opt-in
because a conversation's worth of bodies is the largest response docket produces. The
text body comes along with the markup because a text-only reply in the middle of a thread
has nothing else to render.html_truncated: true says the markup you hold is incomplete. It is separate from
truncated, which is about body: the cap applies to each body on its own, and an html
part is routinely an order of magnitude larger than the text beside it, so one flag for
both would report a whole text body as cut. Truncated markup is cut back to sit after the
last complete tag and character reference, so it parses — a cut left mid-tag does not, a
parser swallows the remainder of the document into an attribute value. Elements left
unclosed are fine; every html parser closes those implicitly.mail attachment writes the bytes to --out and returns the path, size,
mime type, filename and a sha256 of the content in the envelope. The bytes do not go to
stdout: stdout carries the JSON envelope, and a caller that has to locate the envelope
inside a stream of PNG bytes cannot read a failure at all — which is the one thing it must
never lose. Nothing is resized, re-encoded or sniffed; the file holds what the sender sent.ok cannot express — a call where three of five
parts are gone is neither a success nor a failure.0600, so an interrupted fetch leaves no short file
for a later pass to mistake for a complete one, and a corpus of private mail is not left
world-readable by whatever umask happened to be in force. An existing file at --out is
overwritten: a resumed backfill re-fetches what it already has.--max-bytes on attachment means the same as everywhere else, 0 included, and defaults
to 10 MB. Over the cap is ATTACHMENT_TOO_LARGE, not a truncated file — half a PNG is not
a smaller PNG, and once written to disk it is indistinguishable from a whole one. The cap
is checked against the size in the message metadata before the content call, so an
oversized attachment costs one cheap request rather than a download; it is checked again
against the bytes received, because the metadata size is Gmail's claim and not a
measurement. mail read reports every attachment's size, so a caller can skip one
without asking for it at all; the cap is there for the accident.MESSAGE_NOT_FOUND (exit 4, the message is
gone), PART_NOT_FOUND (exit 4, the message is there and has no such part — the error
names the part ids it does have), ATTACHMENT_UNAVAILABLE (exit 4, the part is there with
no content behind it), and OUTPUT_WRITE_FAILED (exit 1, the bytes arrived and the local
write failed — nothing about the mailbox is wrong). A rate limit stays RATE_LIMITED,
exit 5, retryable: true: reported as a missing part it would be recorded as a permanent
gap and never asked for again.attachments entries carry content_id for the parts an html body references by
cid:, with the angle brackets stripped so it matches the URL token directly. Without it
a consumer holding cid:ii-9f3c2a@mail.example.com has no way to say which part id that
is, and an inline screenshot — content, not decoration — stays a broken image.--verbose adds the threading/cc columns to the terminal table. JSON output always
contains every field, so it does nothing for a programmatic caller. --all is a
deprecated alias.docket cal — Google Calendar (CalDAV + client-side RRULE expansion)| Subcommand | Purpose |
|---|---|
cal agenda [--days 7] | Upcoming events |
cal show --id <event-id> | One event (id form <uid>::<RFC3339 start>) |
cal freebusy --start ... --end ... | Busy ranges in a window |
cal find-slot --duration 45m --within 5d --hours 09:00-17:00 | Find free time — the highest-value command |
cal create --summary ... --start ... --duration ... | Create (mutating: --confirm) |
cal update --id <event-id> ... | Update (mutating: --confirm) |
cal delete --id <event-id> | Delete (mutating: --confirm) |
Notes:
--tz is required on every time-parsing command (IANA zone, e.g.
Australia/Melbourne). Google's CalDAV interface can't cheaply report a calendar's
configured timezone, and guessing causes silent off-by-hours bugs.cal create accepts --rrule as a raw RFC 5545 value ("FREQ=WEEKLY;BYDAY=MO,WE,FR"),
plus optional --attendees, --location, and --idempotency-key (reusing a key on retry
prevents duplicate events).--calendar defaults to the account's primary calendar for
reads, and to default_calendar in config.toml for writes. agenda/freebusy/
find-slot accept --calendar all to merge every calendar (so busy slots respect
holidays/shared/work calendars).agenda/freebusy for minutes via the legacy
CalDAV time-range query — trust create's own return value (or cal show on the returned
id) rather than immediately re-querying agenda to confirm.cal update --location "" can't blank a field (empty == "not provided").Every command emits one JSON envelope on stdout:
{ "ok": true, "data": { }, "warnings": [], "error": null }
search/list add a page object describing what you are holding:
{ "ok": true, "data": [ ],
"page": { "returned": 500, "limit": 500, "has_more": true, "next_page_token": "09vv…" },
"error": null }
{ "ok": false, "data": null,
"error": { "code": "AUTH_EXPIRED", "message": "refresh token rejected", "retryable": true } }
Two invariants hold for every command, enforced where the envelope is serialised:
ok: false always carries a non-null error with a populated code, message and
retryable, and always exits non-zero. A caller reading ok and then error.message
never has to handle a null.retryable: true is claimed only for causes known to be transient — rate limits, 5xx,
network timeouts, a refresh worth reattempting. A usage error, a missing message, or an
unrecognised failure reports false, so a client that backs off on retryable never
loops on something that cannot succeed.Mail error codes a caller may branch on: RATE_LIMITED, AUTH_EXPIRED, AUTH_REVOKED,
PERMISSION_DENIED, MESSAGE_NOT_FOUND / THREAD_NOT_FOUND, GMAIL_SERVER_ERROR,
NETWORK_ERROR, TIMEOUT, USAGE_ERROR, SEND_FAILED, CONFIRM_REQUIRED,
WRITES_DISABLED, GMAIL_API_ERROR (unclassified).
Exit codes (an agent's control flow is driven by these, not by parsing prose):
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Everything else |
| 2 | Usage error — malformed invocation (message names the fix) |
| 3 | Auth required / expired — needs a human |
| 4 | Not found |
| 5 | Rate limited or transient — safe to retry with backoff |
| 6 | Refused: mutating command without --confirm, or writes disabled |
Code 6 is the interesting one: it lets an agent propose a write, get a structured refusal describing exactly what would have happened, and surface that for approval — rather than acting unilaterally.
Writes can be shut off (or scoped) without touching agent-facing flags or redeploying —
handy for a systemd unit's Environment=. Any non-empty value counts as set.
| Variable | Effect |
|---|---|
DOCKET_READONLY | Disables all mail and calendar writes |
DOCKET_MAIL_READONLY | Disables mail send/reply/label only |
DOCKET_CAL_READONLY | Disables cal create/update/delete only |
DOCKET_CAL_OWN_EVENTS_ONLY | cal update/delete refuse anything docket didn't create (via a [docket] marker in the event description) — a middle tier between read-only and full write |
These combine into three deployment tiers: fully open, own-events-only, fully closed.
A refusal here is WRITES_DISABLED / NOT_OWNED, exit code 6 — refused, not failed.
cmd/docket/main.go thin CLI shell over the gmail library
gmail/ reusable library module (github.com/zachpmanson/docket/gmail)
config/ default config.toml embedded into the auth package
auth/ provider config, PKCE login flow, flock'd token store
mail/ Gmail REST v1 wrapper, MIME part walking, labels
cal/ CalDAV client, RRULE expansion, derived free/busy, find-slot
errors/ shared process exit codes
internal/
out/ result envelope, TTY detection
flake.nix devshell: Go toolchain, gopls, golangci-lint
docket-design.md design rationale, known limitations, build history
For the gory details — why CalDAV over the REST API, the client-side RRULE expansion gaps,
the flock-guarded token refresh, the write-safety gate, and known risks (the borrowed
client, PKCE/redirect drift, blast radius) — read docket-design.md.
It's the source of truth; this README is the elevator pitch.
Go
99.4%