No description
  • TypeScript 99.9%
  • Shell 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Markus Oberlehner a08fc0df78
All checks were successful
ci / build (push) Successful in 29s
ci / formatting (push) Successful in 34s
ci / lint (push) Successful in 48s
ci / tests (push) Successful in 2m37s
ci / types (push) Successful in 28s
ci / release (push) Successful in 25s
chore(deps): update dependencies to latest
msw 3 renames onUnhandledRequest to onUnhandledFrame. TypeScript stays on 6.x.
2026-10-07 13:13:41 +02:00
.agent/skills/qa fix: qa seed finds its folder past the first page 2026-09-17 19:03:08 +02:00
.claude docs: route to what is needed instead of stating everything 2026-09-15 22:31:02 +02:00
.forgejo/workflows ci: publish with a token that may write packages 2026-09-18 20:19:28 +02:00
.githooks chore: lint and format staged files before a commit 2026-09-15 20:33:58 +02:00
adr feat(mirror): rank semantic search by summed similarity, with story text 2026-09-23 13:25:28 +02:00
bin feat: add a token-frugal CRUD CLI for stories 2026-09-14 18:07:42 +02:00
docs fix(mirror): hand the lease back promptly on a stop 2026-09-23 20:04:57 +02:00
eval docs: quote the faster session 2026-09-16 21:55:47 +02:00
skills/storyblok-content-ops feat(mirror): rank semantic search by summed similarity, with story text 2026-09-23 13:25:28 +02:00
src chore(deps): update dependencies to latest 2026-10-07 13:13:41 +02:00
.env.qa.template feat: measure what a session costs, against the MCP server 2026-09-16 19:49:07 +02:00
.git-blame-ignore-revs chore: ignore the reformatting commit in blame 2026-09-16 18:15:26 +02:00
.gitignore chore: keep brainstorming specs and plans local 2026-09-18 16:51:19 +02:00
.lintstagedrc.json style: format at prettier default print width 2026-09-16 18:04:51 +02:00
.prettierignore feat: add a token-frugal CRUD CLI for stories 2026-09-14 18:07:42 +02:00
.prettierrc.json style: format at prettier default print width 2026-09-16 18:04:51 +02:00
AGENTS.md feat(mirror): semantic search over embedded block texts 2026-09-23 12:44:16 +02:00
CLAUDE.md feat: add a token-frugal CRUD CLI for stories 2026-09-14 18:07:42 +02:00
eslint.config.ts feat: measure what a session costs, against the MCP server 2026-09-16 19:49:07 +02:00
GAPS.md docs: trim prose and correct stale behavior descriptions 2026-09-19 16:22:12 +02:00
LICENSE feat: add a token-frugal CRUD CLI for stories 2026-09-14 18:07:42 +02:00
package.json chore(deps): update dependencies to latest 2026-10-07 13:13:41 +02:00
pnpm-lock.yaml chore(deps): update dependencies to latest 2026-10-07 13:13:41 +02:00
README.md docs: trim prose and correct stale behavior descriptions 2026-09-19 16:22:12 +02:00
tsconfig.json feat: measure what a session costs, against the MCP server 2026-09-16 19:49:07 +02:00
tsdown.config.ts fix(mirror): emit declarations for the mirror entry 2026-09-23 08:45:01 +02:00
vitest.config.ts feat(mirror): sba mirror run, status and migrate 2026-09-23 09:14:27 +02:00
vitest.setup.ts feat: journal story writes and name the change set 2026-09-18 16:51:48 +02:00

storyblok-agent

Find and change Storyblok content from the command line.

Ask where a term occurs and get back the exact locations, then rewrite only those fields. Renaming a product across a hundred stories, or fixing one headline, takes a command instead of a trip through the editor.

Built for AI agents and scripts: field-level results and writes keep whole stories out of the caller's context.

What an agent does with it

Our product "Storyblok Labs" is now "Storyblok Studio". Rename it everywhere.

sba stories replace "Storyblok Labs" --with "Storyblok Studio"
{"id":123,"slug":"blog/launch","changed":["/content/body/0/headline","/content/body/1/text"]}
{"id":124,"slug":"pages/about","changed":["/content/intro"]}
{"note":"50 stories rewritten, 50 of them changed.","stories":50,"changed":50}

The free trial is now 30 days instead of 14. Update every mention so each sentence still reads right.

sba stories grep '14[- ]day|two weeks' --regex
{"story":456,"slug":"blog/pricing-faq","path":"/content/body/2/text","value":"Every plan starts with a 14-day trial, which is two weeks to try it with your team.","term":"14[- ]day|two weeks"}

Each match comes back with the string it sits in, so the agent reads the sentence, not the story, and rewrites only the part that changes:

sba stories update blog/pricing-faq --edit /content/body/2/text \
  --old 'a 14-day trial, which is two weeks to' \
  --new 'a 30-day trial, which is a full month to'
{"id":456,"slug":"blog/pricing-faq","changed":["/content/body/2/text"]}

How many stories have no meta title?

sba stories select /content/meta_title --absent | wc -l

Every "Learn more" button should name the product its story is about.

sba stories grep '^Learn more$' --regex --select /content/body/0/headline \
  | jq -c '{story, ops:[{op:"replace", path, value:("Explore " + (.selected["/content/body/0/headline"] | split(" — ")[0]))}]}' \
  | sba stories apply
{"id":123,"slug":"blog/launch","changed":["/content/body/4/label"]}
{"note":"50 stories written, 50 of them changed, 0 failed.","stories":50,"changed":50,"failed":0}

"Pricing 2023" is superseded by the pricing page. Delete it without breaking anything that links to it.

sba stories delete blog/pricing-2023 --replace-with blog/pricing --publish
{"id":789,"slug":"blog/pricing-2023","deleted":true,"descendants_deleted":0,"replaced":[{"id":123,"slug":"pages/home","updated_at":"2026-09-17T11:00:00.000Z","changed":["/content/body/2/link/id","/content/body/2/link/cached_url"]}]}

Without --replace-with the delete is refused and lists every link it would break. Assets work the same way.

Export the images nobody uses, then delete the ones older than this year.

sba assets unused > unused.jsonl
jq -r '[.id, .filename, .created_at] | @csv' unused.jsonl > unused.csv
jq -c 'select(.created_at < "2026-01-01")' unused.jsonl | sba assets delete
{"id":456,"deleted":true}
{"note":"212 assets of 1840 are used by no story, of 3951 stories read.","assets":1840,"unused":212,"stories":3951}

Every story is read once, in every language and its published version, and each delete checks for uses again before the file goes.

Which images on the blog have no alt text? Give me a CSV.

sba assets missing-alt --starts-with blog/ > missing-alt.jsonl
jq -r '[.kind, .id // .asset, .slug, .path, .filename] | @csv' missing-alt.jsonl > missing-alt.csv

missing-alt.jsonl:

{"kind":"asset","id":456,"filename":"https://a.storyblok.com/f/12345/1200x800/abc/hero.png","alt":""}
{"kind":"use","asset":457,"filename":"https://a.storyblok.com/f/12345/400x400/def/logo.png","story":123,"slug":"blog/launch","path":"/content/body/0/image/alt","library_alt":"The logo"}

Both are checked: the library's alt text, and the copy each story keeps per image, which is what the page renders.

We wrote alt text for the logos in the library. Make the pages use it.

sba assets push-alt --in-folder Logos --publish
{"id":123,"slug":"blog/launch","updated_at":"2026-09-17T11:00:00.000Z","changed":["/content/body/0/image/alt"]}
{"note":"38 uses in 31 stories got alt text.","stories":31,"uses":38}

Only empty alt text is filled; --overwrite also replaces text that differs.

That rename was wrong. Put it back.

sba undo --last
{"reverts":0,"kind":"story.patch","id":123,"slug":"blog/launch","action":"reverted","reverted":["/content/body/0/headline"]}
{"note":"journaled","change":"01J8Z3Q4N5W6X7Y8Z9A0B1C2D3","writes":50}

Every write is journaled, one change set per command: updates, publishes, deletes, assets and their folders. An undo puts back what the write changed and nothing else — an edit made since, elsewhere in the story, is kept, and one to the same field refuses the whole change set instead of overwriting it. A deleted folder is created again with the links between its stories mended.

Results go to stdout, one per line; note lines go to stderr.

See Performance for measured session costs and an MCP comparison.

Setup

Install from the forge's public npm registry:

npm config set @markus:registry https://code.senpen.eu/api/packages/markus/npm/
npm install -g @markus/storyblok-agent

In a project, put the first line in the project's .npmrc instead, so everyone who installs it resolves the same way:

@markus:registry=https://code.senpen.eu/api/packages/markus/npm/

The binary is sba (storyblok-agent also works).

The skill

The documentation is also an agent skill, so the agent reads the same pages you do. Install it with skills, either from the forge:

npx skills add https://code.senpen.eu/markus/storyblok-agent.git --skill storyblok-content-ops

or, if the package is already a dependency of your project (npm install -D @markus/storyblok-agent), from node_modules, which keeps the skill on the version you install:

npx skills add ./node_modules/@markus/storyblok-agent --skill storyblok-content-ops

Either writes it to .claude/skills/ and the equivalent for whatever other agents you pick; -g installs for every project instead. --skill matters on the forge source: a clone also carries the skills this repo uses on itself.

Usage

export STORYBLOK_SPACE=12345
export STORYBLOK_TOKEN=your-personal-access-token

sba stories grep Foo --starts-with blog/
sba stories update 123 --set /content/body/0/headline="Hello Bar"
sba stories replace Foo --with Bar --dry-run
sba components get teaser --example

The space and the token can also come from a .env file, a storyblok-agent.config.* file, or a .storyblok-agentrc. See configuration.

Every command prints JSON on stdout, one document per line, so output pipes into jq.

Performance

A four-part job against a space of 150 stories, each carrying about 17KB of nested content: rename a product wherever it occurs across 50 of them, change one story's headline, count the stories missing a field, and rewrite 50 button labels so each names the product its own story is about.

One Claude Code session on Sonnet did all four correctly for $0.30, in 18 turns and under 3 minutes, using 16 tool calls — nine of them sba. No story was read in full to change a field in it.

Recorded 2026-09-16 in the run results. The harness documents the seed, setup, and limitations.

Why a CLI

Pipelines such as | jq and > file transform and store results without sending them through the model. Pointer-based commands also let the caller change a field without supplying the whole story.

In the paired run below, tool results reaching the model totaled 2.9MB through Storyblok's MCP server and 50KB through this CLI. Tool input totaled 1.57MB and 7KB respectively, largely because the MCP run sent whole story bodies for updates.

What that costs in practice

The same four-part job was run through Storyblok's MCP server and this CLI. Both completed it correctly. These figures are from one paired run, separate from the $0.30 session above:

MCP server storyblok-agent
Cost $17.70 $0.41
Tool calls ~330 20
Turns 57 22
Wall clock 23 min 4 min

These measurements describe the tested tools and workflow. The MCP run used field selection for listings, then introduced a local Python script and direct API calls for bulk updates. See the evaluation harness for the setup and limitations.

Docs

The agent skill ships in skills/storyblok-content-ops/:

  • Concepts: setup, configuration, output, pointers, scoping, bulk reads, safe writes.
  • Stories: list, search, grep, select, get, create, update, replace, apply, publish, unpublish, restore, delete.
  • Recipes: tasks that take more than one command.
  • Components: what a space defines, and how to build a block.
  • Assets: the asset library and its folders.
  • Undo: undo, history, and what a change set is.
  • Gaps: what this tool does not do yet, and what to reach for meanwhile.

Run sba --help for the full command list, or sba <command> --help for one command.

Not this tool

This tool is about content operations only. Schema changes and content migrations are Storyblok CLI territory.