Skill issue: storyblok-use-cli
Storyblok is the first headless CMS that works for developers & marketers alike.
Is your agent having trouble navigating migrations? Does it spend forever picking through API endpoints for simple tasks while you wait for schemas to update? Is it trying and failing to push component changes over and over again?
Here to save the day, it’s storyblok-use-cli!
This skill helps an agent learn how to handle spaces, schemas, and migrations with ease—thanks to this skill, you can be certain that your transfer of stories, components, assets, and datasources will be handled automatically and responsibly, keeping you informed of every success and any obstacle along the way.
With this skill, you can be sure your agents know exactly how to navigate the CLI and manage content and schemas with confidence.
The Storyblok agent skills library is an open source set of skills that teach an AI coding agent how to work with Storyblok: when to use the CLI vs the MCP server, modeling schemas, reading and writing content through the Storyblok MCP server, turning a design into a page, and more.
Why use this skill?
To show the benefits of using this skill, we tasked two separate agents with performing the same series of actions on a pair of Storyblok spaces: one with the storyblok-use-cli skill enabled, and one without.
All prompts were tested using Claude Code running the Storyblok Skills package through Opus 5, while connected to the Storyblok MCP server. This test uses a populated Storyblok Demo Space called “CLI Demo Space — Populated” and a nearly empty new space called “CLI Demo Space — Empty”.
Remember that systems involving agentic integrations are probabilistic, not deterministic. Expected output can be predictable but never fully guaranteed, which means that the output seen here may not exactly reflect the output another run produces under the same conditions.
For this comparison, we stress-tested our agents by tasking them with a step-by-step migration from the populated space to the unpopulated space. They were told to copy blocks; generate type definitions; duplicate datasources, assets, and stories; then finally generate and run a migration script.
Both agents were given the following prompt:
Copy all blocks from “CLI Demo Space — Populated” (<space ID>) over to “CLI Demo Space — Empty” (<space ID>). When this is finished, generate type definitions from the component schemas that were just added, then pull the datasources, assets, and stories from “CLI Demo Space — Populated” and add them into “CLI Demo Space — Empty”. When that is complete, generate then run a migration script. Without skills
Immediately into the unskilled run, the agent tried to map out the Storyblok CLI on its own. Because of the number of options and flags available, it got completely lost trying to find the correct commands it actually wanted to use, but did ultimately find what it was looking for and began analyzing the two spaces in question.
I'm reading the options for each Storyblok CLI subcommand before running anything. The help output keeps mixing in the global help, so I'm narrowing it down to the per-command options.
I have the options. Next I'll check that the target space is actually empty before writing anything to it.
The target space only has Storyblok's default blocks with no naming conflicts, so I'll now push over the 33 blocks, 3 groups, and 20 presets from the source. The agent soon noticed that one thing it was trying to push was not resolving properly, and after going over its own notes determined that without a specific custom field, the products-section block would not be able to migrate properly.
It then paused the run to ask what to do regarding the component, with options ranging from the user manually adding the custom field (as the agent did not have the ability to do this automatically), to pushing the block with the field removed, to skipping the field altogether.
For the purpose of this run, we chose to have the field removed from the block to allow a version of it to transfer between spaces.
One item out of 56 failed to push. I'll check the report to see which one and why.
The block that failed is products-section, but the CLI hides the API's reason. I'll look at its schema directly.
I suspect the plugin field's custom type sb-fake-ecommerce isn't installed in the target space. I'll confirm by creating the block via the Management API to see the actual error message.
Found the issue: products-section was rejected because the sb-fake-ecommerce field-type plugin isn't available in this space, though the other 55 items (32 blocks, 3 groups, 20 presets) copied fine. Since this affects downstream types, stories, and migration, I'll pause here to check how you'd like to proceed before continuing.
⏺ User answered Claude's questions:
· products-section uses the 'sb-fake-ecommerce' field plugin, which isn't installed in the Empty space. How should I handle it? → Push without plugin field
I'll push products-section through the CLI from a scratch copy of the schema with the plugin field removed, so the CLI still maps it to the right block group. From here, the unskilled agent moved on to type definition generation, as well as copying datasources, assets, and stories, before running into a handful of problems. Various stories failed to resolve due to null content, collisions, and a changed property in secondary_background_color.
The agent had some difficulty nailing down exact reasons for difficulties at first, but was generally able to identify if not solve what was causing problems in the first place.
Next I'll generate type definitions from the target space's schemas, now that its blocks are in place.
That generated 37 interfaces: the 33 copied blocks plus the 4 defaults. Next I'll pull the datasources from the source and push them to the target.
All 6 datasources are in the target. Next I'll pull the assets and stories from the source, then push the assets to the target.
The source space has 0 assets, so there's nothing to copy. I'll check what the stories' image links point to, then push the stories.
The push partly failed: 2 stories weren't created, 3 failed processing, and 1 failed to update. I'll check the report for details.
Some stages failed: folders hit a CLI bug from their null content (but were still created), while home and articles/ likely conflicted with the space's default home story. I'll check what's currently in the target space.
27 stories copied, but services is stuck as a placeholder, articles/ is missing, and home doesn't match the source's default page. I'll map source home to the existing target home and re-run the push to fix these issues.
Two stories still fail on the re-run: articles/ won't create and services won't update. The CLI hides the API's response, so I'll send both requests directly to get the real errors.
articles/ now exists in the target but still has unmapped references, so I'll add it to the manifest for remapping on the next push. Moving on to the services update error.
Found a mismatch: hero-section.secondary_background_color is now a plain option field, but the services story still holds an old palette-plugin object, which the target rejects with a 422. I'll check how many other stories have this same leftover issue.
That's the only occurrence. I'll check whether the hex value #A59441 matches an entry in the field's datasource.
Since that hex value doesn't match any datasource entry and isn't used elsewhere, I'll push services from a temp copy with it removed, then re-run the push for articles/ and home, and finally run a hero-section migration to default any missing/invalid secondary_background_color to primary-background. Because of changes made to the hero-section block, the unskilled agent generated a migration script to define what was being altered on its way from one space to the next. Once this was complete, the agent was able to finish the dry run and successfully complete the task given to it.
All stories have landed successfully — the only remaining "failures" are 3 folders hitting a known CLI null-content bug, but they already exist in the target. I'll now pull the target to confirm no __tmp__ placeholders remain and no references still point to the source space.
All 30 stories match the source: same slugs, same block types, and no references left pointing at the source. Next I'll generate the hero-section migration.
⏺ Write(.storyblok/migrations/123456789012345/hero-section.js)
Added 28 lines, removed 8 lines
// Normalize hero-section.secondary_background_color to a valid value of the
// `highlight-and-background-colors` datasource. Legacy content stored this field
// as a storyblok-palette plugin object ({ value: '#A59441', plugin: 'storyblok-palette' }),
// which the current `option` field type rejects. Missing or invalid values fall
// back to the schema default.
const VALID_COLORS = new Set([
'primary-highlight',
'highlight-1',
'highlight-2',
'highlight-3',
'white',
'primary-background',
'background-1',
'background-2',
'background-3',
'background-4',
'background-5',
'background-6',
'background-7',
'background-8',
'background-9',
'background-10',
]);
const DEFAULT_COLOR = 'primary-background';
export default function (block) {
// Example to change a string to boolean
// block.field_name = !!(block.field_name)
if (!VALID_COLORS.has(block.secondary_background_color)) {
block.secondary_background_color = DEFAULT_COLOR;
}
// Example to transfer content from other field
// block.target_field = block.source_field
// Example to transform an array
// block.array_field = block.array_field.map(item => ({ ...item, new_prop: 'value' }))
return block;
}
The dry run shows exactly what I expected: services gets updated, and about and home are skipped because they already have valid values. Now the real run.
Everything is now in "CLI Demo Space — Empty" (123456789012345). Two things were changed along the way because the target space rejected the source data as-is: a block field was dropped and one story value was rewritten. At the end of it all, the agent presented a detailed summary of what it did and didn’t do, as well as changes made by it in order to accomplish the task given to it. It’s worth noting that the unskilled agent did not publish any stories, leaving everything as a draft.
┌──────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Step │ Result │
├──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Blocks │ All 33 blocks, 3 groups and 20 presets copied. The target now has 37 blocks, counting its 4 │
│ │ defaults. │
├──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Type │ Generated 37 interfaces in .storyblok/types/123456789012345/storyblok-components.d.ts. │
│ definitions │ │
├──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Datasources │ All 6 copied, 45 entries in total. │
├──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Assets │ Nothing to copy: the source space has 0 assets. The stories' images are hosted in three other │
│ │ spaces, so the links keep working. │
├──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Stories │ All 30 copied. I compared both spaces afterwards: same slugs, same block types, and no │
│ │ references still point at the source. │
├──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Migration │ Wrote and ran .storyblok/migrations/123456789012345/hero-section.js: 1 story updated, 2 skipped │
│ │ because nothing needed changing. Rollback files are in rollbacks/. │
└──────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────┘
The two changes:
- products-section has no plugin field. That field needs the sb-fake-ecommerce app, which isn't installed in the target space. As you chose, I copied the block without it. The stories still hold their plugin values, but the editor won't show them unless you install the app and add the field back.
- services had an old value in hero-section.secondary_background_color. It was stored as a leftover color-picker object ({"value":"#A59441",...}). The field is now a plain dropdown, so the target rejected the story. I pushed it without that value, and the migration set it to the field's default, primary-background. #A59441 doesn't match any entry in the field's color list, so the default was the only valid choice. It's the only story affected.
Other things to know:
- home was replaced. The target already had a default home story, so I pointed the copy at that story instead of creating a second one. Its content is now the source's home page.
- articles/ was created by a direct API request. The CLI kept failing on this page, so I created it with a direct request, and a re-run of the CLI push then updated its content.
- Folders log an error but are fine. Every story push reports 3 failures for the articles, banners and categories folders. That's a CLI bug with folders, which have no content. All three exist in the target.
- Nothing is published. The stories and the migration change are saved as drafts.
- Old output is in a backup folder. With skills
The skilled agent began by immediately loading both storyblok-use-storyblok and storyblok-use-cli skills, then identifying the region the CLI was logged into in order to access the correct spaces. Having the information handy right away, the agent was able to move quickly and did not get bogged down trying to figure out its tools.
⏺ Skill(storyblok:storyblok-use-storyblok)
Successfully loaded skill
⏺ Skill(storyblok:storyblok-use-cli)
Successfully loaded skill
The CLI is logged in on the EU region, which matches both spaces. Following this, the agent was able to successfully copy nearly all of the components from the populated space, with products-section once again failing. This time, however, the agent proceeded on its own, pushing the component with the tricky custom field removed, and moved on to type generation, datasource duplication, and asset migration:
Read 1 file, ran 5 shell commands
Copied 55 of 56 component items. Only products-section failed. Next I'm checking the target space to see why.
Searched for 1 pattern, called plugin:storyblok:storyblok 3 times, ran 2 shell commands
The products-section likely fails because its plugin field needs the sb-fake-ecommerce custom field plugin, which isn't installed in the Empty space. I'll test this by pushing the component without that field.
That confirmed it: all 33 components are now in the Empty space. products-section is there without its plugin field. Next: pulling the target's components and generating types.
Now the datasources:
All 6 datasources (45 entries) are in the Empty space. Next: assets.
The Populated space's asset library is empty—its stories just reference images from other spaces' public CDN URLs, so there's nothing to transfer there. Moving on to check the stories next. As the agent moved into transferring stories, it performed a number of preemptive checks to try and avoid collisions and issues during the process, including verifying possible duplicate stories, reasoning through potential issues with folder transfers, and testing dry run failures.
Pulled 30 stories. Before pushing, I'm checking whether the Empty space already has stories that would collide with them.
The Empty space has only its default home story. Running a dry run to see how the push treats it:
The 3 dry-run failures are just the folders (articles, banners, categories), which have null content and only fail the content-processing step they don't actually need—so they're fine. Proceeding with the real push now. Here, the agent ran into an interesting scenario where three attempted pushes would not resolve correctly, and was able to determine the sources of the problems by investigating API errors it was receiving back. It identified a possible schema mismatch, as well as a missing start page, prompting it to assign one to the articles/ folder to ensure one exists.
The skilled agent ran into an issue with secondary_background_color as the unskilled agent did before it, but rather than the field being converted to another format entirely before being removed, here the agent sees it as undefined and moves to remove it right away.
Two creates and one update failed with transient httpCode 0 errors, but since the manifest makes re-pushing safe, I'll run it again.
The re-push hit the same 3 failures, so they aren't transient. I'm comparing the two spaces to see which stories are missing.
Found three issues: the articles folder's start page is missing, home still has default content, and services content differs from the source. I'll re-run the push with --verbose to surface the API errors.
I'm still debugging the services update failure plus the home and articles/ creation failures, since the CLI hides the API's actual error details. I'll isolate services and push reduced versions to pinpoint the problematic field.
The failing field is secondary_background_color. Checking its schema:
I'll strip the undefined secondary_background_color from services, and for home I'll map it onto the Empty space's existing default home to avoid a duplicate slug failure. Then I'll retry pushing articles/.
home and services now update successfully. Only the articles/ start page still fails. Checking its content:
Changing the slug got the start page created as articles/overview. Now I'm pushing an update that marks it as the folder's start page, so it resolves at articles/ again: At this point, the agent checked over its work, and generated a migration script to handle the missing custom field from products-section, then after completing its dry run finally proceeded with the real push.
All 30 stories are in the Empty space and their content matches the source. Checking that story-to-story references point at the new stories:
Two stories, error-404 and site-config, still link to home and articles/ by their old source IDs. Those two targets were only mapped after the main push. Re-pushing just those two stories:
All 30 stories are in place with resolving references, and the one failure is just an empty folder as expected. Now I'll generate a migration to remove the orphaned plugin data left in the about story's products-section block.
⏺ Write(.storyblok/migrations/123456789012345/products-section.js)
Added 5 lines, removed 8 lines
// The `plugin` field (custom field type `sb-fake-ecommerce`) does not exist in
// this space: the field plugin is not installed here, so the field was left out
// of products-section when the schema was copied. Drop the orphaned data the
// copied stories still carry for it.
export default function (block) {
// Example to change a string to boolean
// block.field_name = !!(block.field_name)
delete block.plugin;
// Example to transfer content from other field
// block.target_field = block.source_field
// Example to transform an array
// block.array_field = block.array_field.map(item => ({ ...item, new_prop: 'value' }))
return block;
}
The dry run touches only about, the one story with a products-section block. Running it for real: The push went off without a hitch, and the agent presented a thorough report in which it detailed the fixes it wound up making along the way.
Everything is now copied into "CLI Demo Space — Empty", and the migration has run. Two workarounds were needed, described under the blocks and stories below.
┌──────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Step │ Result │
├──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Blocks │ All 33 components, plus 23 presets and component groups, copied. │
├──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Type │ 37 types generated from the Empty space's components into │
│ definitions │ .storyblok/types/123456789012345/storyblok-components.d.ts. │
├──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Datasources │ All 6 copied, with all 45 entries (checked by pulling them back). │
├──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Assets │ The Populated space's asset library is empty, so there was nothing to transfer. Its stories │
│ │ point to images hosted on other Storyblok spaces' public URLs, which still load. │
├──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Stories │ All 30 copied (checked against the source), and all 39 links between stories point to the new │
│ │ copies. │
├──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Migration │ Generated and run for products-section: 1 story changed (about), checked by pulling it back. │
└──────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────┘
Blocks: products-section failed at first. Its plugin field needs a custom field plugin (sb-fake-ecommerce) that isn't installed in the Empty space. I pushed the component without that field.
Stories: three needed fixes before they would copy:
- services: its hero block held leftover data called secondary_background_color, which isn't defined in the hero-section block in either space and references a colour plugin that isn't installed. The API rejected the story until I removed it from the local copy. Editors never saw this field, so nothing visible was lost.
- home: the Empty space already had a default home story, so I overwrote it with the source version instead of creating a second one.
- articles/ (the folder's start page): the API refused to create it under its original name, so I created it under a different one and then made it the folder's start page. It is now at articles/ with its original slug.
Migration: .storyblok/migrations/123456789012345/products-section.js removes the leftover plugin data (the selected products), which nothing in the Empty space can display. That data still exists in the Populated space, and a rollback file is in .storyblok/migrations/123456789012345/rollbacks/. Comparison and conclusion
While both agents were ultimately able to complete the tasks given to them, there was a clear difference in approach.
- The unskilled agent visibly had difficulty figuring out how best to use the CLI, and in some cases even deferred to hitting API endpoints directly when it couldn’t find what it needed, as it had access to did not have access to the
storyblok-use-mcpskill either. - The skilled agent not only had more direct approaches to each of the sub-tasks, but also was more proactive in solving problems as they came, from missing custom fields to assigning folder start pages. The unskilled agent took comparatively more elaborate routes to accomplish similar tasks.
- The unskilled agent didn’t publish anything it migrated over, regardless of publish status in the original space.
Both agents took a similar amount of time (anywhere between 6–12 minutes among several test runs) and both also spent around 65,000 tokens to accomplish this hefty task.
The biggest takeaway between the two approaches is the confidence and decisiveness of the skilled agent, able to handle problems it came across without requiring the user to weigh in. It’s clear the skilled agent would be better suited for handling the intricacies of larger, more complicated migrations.
Get the skills
To make use of the skill library in your projects, simply install the Storyblok Agent Plugin; it sets up everything your agent needs to perform at the level of an experienced Storyblok Developer.
We want your feedback
We would love to hear back from you on how you use these skills and how they are working for you! Let us know in our Discord.






