Morning · 7 May 2026

I spent the morning shipping canvacli — a command-line tool for the Canva Connect API. It does the obvious things you'd expect from a Canva CLI: log in, list your designs, generate new ones from brand templates, export to PDF or PNG. What's less obvious is who I built it for. Not me at the terminal. The agent in the next pane over.

The gap

Canva does ship an official CLI — @canva/cli — but it's for building Canva Apps: the panels and integrations that show up inside the Canva editor. There's nothing for the other direction: programmatically using Canva from the outside. If I want to ask Claude Code to "generate me twenty social posts from this CSV and export them all as PDFs", there's no tool to hand it. It either calls the REST API directly (verbose, easy to mess up) or it doesn't do it.

That's the gap. So canvacli sits between the agent and the Connect API and turns "use Canva" into something an agent can actually drive in three or four shell calls.

What "agent-first" actually means in practice

It's a phrase that gets thrown around. For me it cashed out as a handful of concrete design choices, all of which I'd flip the other way for a human-first CLI.

Stable, structured output by default. When stdout is piped or redirected, every command emits JSON or NDJSON. When stdout is a TTY, output is human-readable. The agent doesn't have to ask for --json — it gets it because it's piping. A human running canva list in their terminal still gets a pretty table.

Errors are structured too. Every error to stderr is a JSON envelope with an error code, a message, an exit_code, and — this is the part I care about most — a fix field containing a literal command the agent can execute to recover.

{
  "error": "design_not_found",
  "message": "no design matched 'banner'",
  "fix": "canva list --json | jq '.[] | select(.title | test(\"banner\"; \"i\"))'",
  "exit_code": 3
}

Agents are good at running commands. They're less good at improvising when something fails. If I tell them what to type next, the recovery loop closes itself.

A static schema you can prime the agent with once. canva schema --full emits a JSON description of every command, every flag, every error code. You drop it into your agent's context once and it can invoke anything in the CLI without ever calling --help. There's a token-budgeted --compact variant for when context is tight.

A read-only SQL escape hatch. Every API response gets cached in a local SQLite database. canva sql "SELECT raw_json FROM designs WHERE id = '...'" lets the agent reach for fields the CLI doesn't surface, without me having to anticipate every column up front. The handle is opened in query_only mode at the engine level so even a clever UPDATE can't slip through. I learned about that the hard way — setting query_only at the connection level isn't enough; SQLite cheerfully accepts writes that the application layer thinks it's blocked.

Why Go

I went back and forth between Rust and Go for a couple of hours before starting. Both make sense. Rust gives me the stronger type system and the better cache crates; Go gives me a single static binary, faster compile cycles for a CLI of this size, and a nicer cobra + viper story for the command tree.

The deciding factor was distribution. brew install needs to Just Work, and goreleaser with a Homebrew tap is a five-minute pipeline. The agent shouldn't have to know about cargo install. The agent shouldn't have to know about anything except brew install catancs/tap/canvacli followed by canva login.

I gave up some type safety. I gained an installer that opens its mouth once and is done.

OAuth 2.0 PKCE, not API keys

Canva Connect doesn't really have a happy path for static API keys outside of partner programs. So canva login runs a proper OAuth 2.0 PKCE flow: it spins up a localhost callback, opens the browser, waits for the redirect, exchanges the code for an access and refresh token, and stores them in the OS keychain (with a config-file fallback for headless environments).

PKCE means there's no client secret on disk. Tokens get rotated automatically when they hit 90% of their TTL. canva logout wipes both the keychain entry and the SQLite cache so the next user of the machine doesn't inherit anything.

Idempotency for create

canva create generates a design from a brand template with autofill data. Agents retry. If an agent's network call times out and it retries the create, I don't want two designs in the user's account.

So the create command takes an optional --idempotency-key. If you don't pass one, canvacli generates a content-hashed key from the template ID plus the autofill JSON and uses that. Two identical creates within the dedup window collapse to one design. The agent doesn't have to think about it.

What I deliberately didn't build

Plenty. The CLI is intentionally narrow. Some of what's missing:

No interactive prompts. Not a single y/n question. An agent piping into stdin can't answer them, and forgetting --yes is the kind of papercut I want to make impossible. Destructive commands take a flag or they refuse to run.

No design editing. Connect doesn't expose primitives for "move this text box left by 20px." I'm not going to fake it. The CLI does what the API actually supports: list, create-from-template, export, organize.

No Canva Apps tooling. That's @canva/cli's job. Different audience, different surface, no point in overlapping.

Enterprise gating (WIP)

A few canvacli commands — canva create and canva templates — depend on Connect endpoints that Canva gates to Enterprise accounts. What I'm building next is a cleaner story around that: detect the plan tier on login, mark gated commands explicitly in the schema, and surface a structured error before the network call instead of a generic 403 from upstream.

Try it

brew install catancs/tap/canvacli
canva login
canva list --limit 5

Drop canva schema --full into your agent's context and let it cook. Repo is at github.com/catancs/canvacli; CLAUDE.md in the root is the agent-onboarding brief.

I'll come back to this one when something actually breaks — that's usually when the more interesting posts get written.

· · ·
Afternoon · 7 May 2026

Came back from lunch and ended up shipping v1.0.0. Three things changed.

The binary is now canva, not canvacli. The package and the repo keep the canvacli name (so links don't rot), but the command you actually type is canva list, canva export, canva login. It reads better in shell history, it reads better in agent contexts, and it lines up with how Canva's docs talk about Canva. The smoke test in CI got updated to invoke the new name; everything else was a one-line goreleaser change.

MCP server mode. The morning post made a big deal about canva schema --full — drop the schema into the agent's context once and it can drive the CLI. That works, but it's the long way around. canva mcp serve spawns a stdio MCP server that exposes canva_whoami, canva_list, canva_folders, canva_export, canva_sql, and canva_schema as first-class tools. Add it to ~/.claude/mcp.json or your Cursor config and the agent picks them up natively. No shelling out. No --help. No schema-priming. Just tools.

The architectural bet is the same as Spendwall's MCP server: most CLIs assume the agent will read --help and figure it out. They won't. They'll Google it, or call the API directly and break, or give up and tell you they can't do it. Ship the tools. Don't make the agent translate.

Embedded credentials via ldflags. The morning version of canva login required users to register their own Canva developer app and set CANVA_CLIENT_ID / CANVA_CLIENT_SECRET. That's the kind of friction that kills adoption. goreleaser now embeds my registered app's credentials at build time via Go's -ldflags="-X". Users get brew install, canva login, done. Custom credentials are still respected if you set the env vars; the embedded ones are the fallback.

Plus: cassette-based integration tests against the real Connect API for the read paths (whoami, list, folders, export), a global --debug flag for tracing, and a few release-pipeline papercuts cleaned up — the homebrew formula was landing in the wrong directory of the tap and the readonly-vs-destructive MCP tool annotations needed correcting.

· · ·
Evening · 7 May 2026

Then I sat down again after dinner and ended up shipping v2.0.0. The morning post described canvacli as a thin CLI on top of the Connect API. By tonight it's something different: a local, queryable mirror of your Canva account that an agent can drive natively.

Pattern A — sync and search. Two commands held the rest of the design together. canva sync walks the entire account in one go — designs, folders, templates, comment threads, assets — and pours it into the local SQLite cache. First sync takes 30 to 60 seconds for a typical account; subsequent syncs are incremental via cursor. canva search "annual report" hits an FTS5 full-text index across the cache with BM25 ranking and returns matches in milliseconds, NDJSON-shaped. The read handle is a separate SQLite connection in query_only mode — same engine-level safety as canva sql.

The implication, written plainly: an agent now has an offline, full-text-searchable view of your entire Canva workspace, plus tools to act on it. "Find me last quarter's social posts that mention compliance and export them as PDFs" becomes a single MCP-call sequence. That wasn't possible at lunchtime.

Pages, resize, import, assets. The expanded API surface. canva pages <design> lists pages with dimensions and thumbnail URLs. canva export --pages 1,3,5 exports a subset rather than the whole thing. canva resize <design> --to presentation creates a sized copy — strict 4-preset enum (doc, email, presentation, whiteboard) because the API only accepts those. canva import <file> brings in PDF, PPTX, DOCX, Keynote, AI/PSD, Affinity, or OpenOffice; image files auto-route to canva assets upload because Canva splits those across two endpoints. canva assets upload <file> pushes images into your asset library and returns an asset ID, which slots straight into a subsequent canva create --autofill for the agent-driven deck workflow.

Comment threads. canva comments add / thread / archive. The archive is local-cache-only on purpose — Canva Connect has no list-threads endpoint, so you get the threads you've interacted with rather than a full server crawl. Annoying limitation but I'd rather honest than fabricated.

13 new MCP tools. Every v2 surface is exposed natively to agents in addition to the 6 that landed this afternoon. Read-only tools are correctly annotated readOnlyHint: true, destructiveHint: false; mutating tools are not marked destructive (autofill creates can be re-run safely under idempotency keys), with canva_create the one exception that's destructiveHint: true. Getting those annotations right matters more than I expected — agent permission systems read them and they're how the user decides whether the call needs explicit approval.

One breaking change for existing v1 users. The v2 commands need new OAuth scopes (comment:read, comment:write, asset:read, asset:write), so the scope set went from 7 to 11. Existing tokens don't carry the new scopes. So: canva logout and canva login once after upgrading to v2 to refresh the token. The v1 commands keep working in the meantime; only v2's comments and assets paths require the re-auth.

Cache schema migrated to v2 with new tables (design_pages, comment_threads, comment_replies, assets, sync_state) and FTS5 virtual tables auto-populated via SQLite triggers. schema_version = 2 is recorded in the meta table so future migrations can step through the history rather than guessing.

v2.0.0 is on the Homebrew tap, the GitHub releases page has signed binaries, and the CHANGELOG covers the v1.1 → v2.0 jump in detail. As far as I'm concerned the tool is feature-complete for what I set out to build today; the next post will be when somebody hits a class of problem I hadn't designed for.