The DatoCMS Blog

Major CLI & CMA changes for managing content programmatically

Posted on May 18th, 2026 by Ronak Ganatra

TLDR

The last couple of weeks brought a set of converging improvements across the CLI, the JS CMA client, and the structured-text packages.​​​​‌‍​‍​‍‌‍‌​‍‌‍‍‌‌‍‌‌‍‍‌‌‍‍​‍​‍​‍‍​‍​‍‌‍‌​‌‍​‌‌‌​‌‍‌‍​‌‍‌‌​​‍‍‌‍​‌‍‌‍‌​‍​‍​‍​​‍​‍‌‍‍​‌​‍‌‍‌‌‌‍‌‍​‍​‍​‍‍​‍​‍‌‍‍​‌‌​‌‌​‌​​‌​​‍‍​‍​‍‌‍​‌‍‌‌​​‍‍‌‍‌​‌‍​‌‌‌​‌‍‌‍​‌‍‌‌​​‍‍‌‍​‌‍‌‍‌​‍‌‍‌‌‌‍‌​‌‍‍‌‌‌​‌‍‌​‍​‍‌‍‍‌‌‌​‌‍‌‌‌‍‌‌‌‌‌​‌‍‌‌​​‌‍‌‌‌​​‍​‍​​​‌​​​​‍‌​​​​‍​​‍‌‍‍‌‌‌​‌‍‌‌‌‍‌‌​​‍‌‌‌​‌‍‌​‌​‍​‌‌‌‌‌‌​​‌‌‌​​‌‌​​‌‌​‌‍‍‍‌​‌​‌‌​‌‍‌‍‌​‌‌‌‌​‍‌​‌‍‍‍​​‌‌‍‌‌‌‌‍‌​​‌​‍‌‍‌‌‌‍‌​‌‍‍‌‌‌​​‍​‌‍‌‍‌‍‍‌‌‍‌‌‌‍​‌‍‌​‌‌​​‌‍​‌‌‌​‌‍‍​​‌‌‍​‌‍‌‍‍‌‌​‌‍‌‌‌‍‍‌‌​​‍​‍‌‌

  • datocms cma:script. Run ad-hoc TypeScript against your project from the CLI, no boilerplate required, standard input mode built for agentic/LLM workflows

  • datocms schema:inspect. Inspect models, fields, and relationships without opening the UI

  • schema:generate now emits Schema.X.ID and Schema.X.REF runtime constants alongside types

  • New FieldValueInRequest<T, K> helper family for typing CMA write payloads correctly

  • isBlockWithItemOfType predicate for narrowing structured text block nodes to a specific model

  • New datocms-structured-text-dastdown package to serialize and edit DAST documents as plain text

  • mapNodes upgraded to support full structural tree rewrites (splat, remove, transform)

  • Records and Update a record docs fully rewritten with end-to-end examples

  • To update: npm i -g @datocms/cli for CLI changes, npm i @datocms/cma-client-node@latest for client changes, npm i datocms-structured-text-dastdown for the new package.


The not-so-great truth about working with the CMA programmatically for a while was that the API itself is powerful, but getting it to behave well with TypeScript (or inside an automated pipeline) required more manual work than it should have.

So we made some dramatic changes to the CLI for the CMA.

This batch of changes addresses both:

  • Better TypeScript inference across the board so the type system catches mistakes before they hit the API.

  • And a ✨new✨ set of CLI primitives that make the CMA usable from automated workflows and LLM-generated scripts.

Closing those annoying TypeScript gaps

Working with block fields through the CMA always required you to know a loooot of context up front: what shape the API accepts on writes (different from what it returns on reads), how to type an accumulator when rebuilding a block array, how to narrow a cluster of blocks down to a specific model... The client didn't help thaaat much either. There were times when fields came back as unknown, and wrong payloads only surfaced as 422s at runtime.

The new FieldValueInRequest<T, K> helper family fixes the write-side typing gap. You pass it the record type and a field key, and it gives you back the exact TypeScript type the API expects for that field on a create or update call, including what each block entry needs to look like.

const page = await client.items.find<Schema.LandingPage>(id, { nested: true });
// typed accumulator
const sections: NonNullable<FieldValueInRequest<typeof page, 'sections'>> = [];
for (const block of page.sections) {
if (isBlockOfType(Schema.HeroBlock.ID, block)) {
sections.push(
buildBlockRecord<Schema.HeroBlock>({
id: block.id,
headline: block.attributes.headline.toUpperCase(),
})
);
} else {
sections.push(block.id); // keep unchanged blocks as IDs
}
}
await client.items.update<Schema.LandingPage>(page, { sections });

Three variants cover different points in the data flow.

  • FieldValue<T, K> for standard responses,

  • FieldValueInNestedResponse<T, K> for when you've fetched with nested: true, and

  • FieldValueInRequest<T, K> for what you're sending back. T accepts a fetched record, a narrowed block, or a Schema.X marker directly.

For structured text specifically, there's now isBlockWithItemOfType/isInlineBlockWithItemOfType which is a predicate that narrows a block node inside a DAST tree to a specific model shape in one step. No extra casting, and it works both, as an inline guard, and as a curried predicate for findFirstNode / filter / find:

// inline guard
if (isBlockWithItemOfType(Schema.CtaBlock.ID, node)) {
// node.item.attributes is now typed as CtaBlock
}
// curried predicate
const firstCta = findFirstNode(content, isBlockWithItemOfType(Schema.CtaBlock.ID));

Oh. And schema:generate now emits runtime constants alongside types (Schema.Article.ID and Schema.Article.REF) so you can stop hardcoding item-type ID strings:

// value position now generated from your project
await client.items.create({
item_type: Schema.Article.REF,
title: 'Hello world',
});
if (item.relationships.item_type.data.id === Schema.Article.ID) { ... }

The CLI can now run scripts directly

Getting a CMA script running previously meant bootstrapping a proper TypeScript project with the whole imports, client setup, tsconfig, etc. etc.. which is a bearable overhead for migrations you're saving, but it's a choooore for one-off jobs.

datocms cma:script fills the gap between cma:call (single API operations, limited) and spinning up a full external TypeScript project just to run a script. You get a pre-authenticated client from datocms link, --api-token, or an environment variable, and two modes depending on what you need.

stdin mode is the fast path , no exports, no imports, just top-level await with client and Schema available as globals:

Terminal window
npx datocms cma:script <<'EOF'
await client.items.create<Schema.Article>({
item_type: Schema.Article.REF,
title: 'Hello world',
});
EOF

Full typechecking runs before anything hits the API. This is also the mode designed for agentic workflows so an LLM can write the script and pipe it to the CLI in a single step, with the type system catching wrong payload shapes before they're executed.

File mode is for anything that needs local helpers or editor LSP support:

Terminal window
npx datocms cma:script tmp/scripts/backfill-slugs.ts

The file uses the migration function signature (export default async function(client: Client)), so when a one-off script is worth keeping, it moves into migrations/ without modifications.

schema:generate now emits runtime constants. Schema.Article.ID and Schema.Article.REF are now generated from your project alongside the TypeScript types, so you can stop hardcoding item-type ID strings that drift out of sync across envs:

// type position: the model's TS shape, as before
const article = await client.items.find<Schema.Article>(id);
// value position: the model's id and ref, generated from your project
await client.items.create({
item_type: Schema.Article.REF,
// …
});
if (item.relationships.item_type.data.id === Schema.Article.ID) {
// …
}

datocms schema:inspect lets you inspect DatoCMS models and modular blocks to emit their structure, fields, and relationships. Pass an exact or fuzzy filter (API key, ID, or display name) to narrow the scope, or omit it to list the entire project.

The command defaults to a compact TOON output and basic field data. Use the --json flag to format output for pipeline tools like jq, and flags like --include-validators or --fields-details=complete to expand field verbosity.

You can also walk the schema graph using specific inclusion flags:

  • --include-nested-blocks — recursively includes nested blocks.

  • --include-referenced-models — pulls in models referenced by link or structured text fields.

  • --include-embedding-models — fetches models that embed the target blocks.

datocms cma:docs now shows the TypeScript signature of the matching client method alongside the docs, so you can see argument shapes and return types without leaving the terminal. Two new flags (--expand-types (inline specific or all reachable type declarations) and --types-depth (control how deep the type walker descends)) let you drill into the type definitions when you need more detail.

Editing Structured Text without touching the AST

Structured Text content lives as a DAST tree which a nested structure of typed nodes. Any programmatic edit has required either writing traversal logic against the AST directly, or skipping it. Neither was really that practical for common operations like bulk find-and-replace across articles, brand renames, or feeding structured content to an LLM for a rewrite.

The new datocms-structured-text-dastdown package gives you a different option. Serialize the DAST tree to a readable markdown-like format, edit it as plain text, and parse it back:

import { parse, serialize } from 'datocms-structured-text-dastdown';
const cur = await client.items.find<Schema.Article>('article-id', { nested: true });
// 1. serialize to dastdown
const text = serialize(cur.body);
// 2. edit as plain text
const edited = text.replace(/Acme Corp/g, '**Acme Inc.**');
// 3. parse back, reusing the original document so untouched blocks
// keep their original payload by reference
const body = parse(edited, cur.body);
await client.items.update<Schema.Article>('article-id', { body });

Embedded blocks appear in the serialized output as <block id="..."/> placeholders so you can move or delete them, but their internal fields are opaque at this layer. For editing block contents or doing structural tree rewrites, you use mapNodes from datocms-structured-text-utils which now supports full structural transforms, not just 1:1 node mapping.

Return a single node, an array to splat into siblings, or null to remove.

The LLM angle here is the same as with cma:script: dastdown output is plain text a model can process directly and pipe back to parse(). Bulk content rewrites, tone adjustments, translation, or any text operation that would previously have required understanding the DAST format can now be handed off to a model with a straight string in and string out.

This really is a great fit for text-heavy content where edits are textual and may cross node boundaries. Think articles, docs, and chapters. Its' not really recommended for landing pages made up of opaque blocks.

Two new tools for working with your schema

datocms schema:inspect is a new CLI command that lets you examine models and blocks without opening the UI. Pass a filter (API key, ID, and display name with fuzzy matching) or omit it to just list the whole project:

Terminal window
# compact overview
npx datocms schema:inspect article
# full details for pipeline use
npx datocms schema:inspect article --json --fields-details=complete | jq '.fields'
# walk the graph
npx datocms schema:inspect landing-page --include-nested-blocks --include-referenced-models

And datocms cma:docs has been enhanced so it now shows the TypeScript signature of the matching client method alongside the documentation, and two new flags (--expand-types and --types-depth) let you drill into type definitions without leaving the terminal.

The records docs got a full rewrite

To cover this in a lot more detail, the Records guide and Update a record guide have been completely rewritten. They now cover block field operations (modular content, single block, structured text) with actual before/after terminal outputs for every operation, and a full treatment of localization update rules, bulk block operations across the content hierarchy, and optimistic locking. Check them out.

Start using DatoCMS today
According to Gartner 89% of companies plan to compete primarily on the basis of customer experience this year. Don't get caught unprepared.
  • No credit card
  • Easy setup
Subscribe to our newsletter! 📥
One update per month. All the latest news and sneak peeks directly in your inbox.
support@datocms.com ©2026 Dato srl, all rights reserved P.IVA 06969620480