Content scripting
The Content Script action runs a script that knows your connected CMS's content model. Inside it, a dari object lets you build and edit content with named types and fields instead of hand-written JSON, and it checks what you write as you write it—a misspelled field name, a value of the wrong type, or a reference where the field expects an embedded object all fail on the line that caused them rather than silently producing content with a field missing.
Use it when a step has to assemble content that no single form can express: composing an article body from several sources, reshaping an import feed into your own types, or applying the same edit across every embedded object in an asset. For anything simpler, a Content action with its Fields box is less work.
This page covers the API. For the fields on the step itself, see the reference.
Before you start
The step needs a Brightspot connection. The connection is where the content model comes from, so nothing on this page works until you pick one.
The plain Script action has no dari object at all. If you find yourself writing dari in a Script step, you want a Content Script step instead.
The dari object
Everything is on one global named dari. Take what you need from it at the top of the script:
1const { Record, RecordRef, RichText } = dari;
The names used on this page are:
| Name | What it is |
|---|---|
Record | A content asset—one of your types, with its fields |
RecordRef | A pointer to an asset that already exists |
Location | A latitude and longitude |
Region | An area, as circles and polygons |
StorageItem | A file the CMS can already reach |
RichText | A rich-text value |
LocalDate, Duration, and the rest | Date and time values—see Dates and times |
diff | Works out what changed between two versions of an asset |
Creating an asset
Name the type, then set its fields:
1const { Record } = dari;23const article = new Record("com.example.Article");4article.put("headline", "Storm closes harbor");5article.put("readCount", 0);
Naming a type
The full internal name always works. When the last part of it is unique across the CMS, that part alone works too:
1new Record("Article"); // same type, when only one is called Article2new Record("com.example.Article"); // always
Matching is case-insensitive, and a full name is tried first — so a type genuinely called Article is never shadowed
by a shorthand match on something else. A nested type keeps its outer class: Content$ObjectModification, not
ObjectModification.
If more than one type shares the ending, the step fails and names the candidates, and you qualify:
1"Renderer" matches more than one content type (com.psddev.cms.db.Renderer,2com.psddev.cms.render.Renderer). Use the full internal name.
Worth knowing: whether a shorthand is unambiguous is a fact about the CMS, not about your script. Installing a plugin that adds a colliding type can make a working script start failing. It fails loudly and the fix is to qualify the name, but a script you want immune to that should use full names.
put returns the record, so it chains:
1const article = new Record("com.example.Article")2.put("headline", "Storm closes harbor")3.put("featured", true);
putAll sets several at once:
1article.putAll({2headline: "Storm closes harbor",3subheadline: "Ferries cancelled through Friday",4featured: true5});
The type name is the full Java class name of the content type, the same name a developer on your team would use. Name one your CMS does not have and the script stops there:
1No content type named "com.example.Nope" on this connection.
Setting fields
Field names are checked against the type, and a name that does not exist lists the ones that do:
1com.example.Article has no field named "headlien". Available: headline, subheadline, body, …
Values are converted to whatever the field holds, so ordinary JavaScript values work:
1article.putAll({2headline: "Storm closes harbor", // text3readCount: 42, // number4featured: true, // true/false5status: "PUBLISHED", // a value from a fixed list6tagNames: ["storm", "harbor"], // a list or set7labels: { en: "Storm" } // a map8});
A field with a fixed set of values checks what you give it:
1Field "status" only accepts DRAFT, PUBLISHED, but got "ARCHIVED".
Other useful calls on a record:
| Call | What it does |
|---|---|
article.get("headline") | Reads a field |
article.remove("headline") | Removes the field |
article.put("headline", null) | Clears the value, keeping the field |
article.has("headline") | Whether the field is set |
article.keys() | Every field name that is set |
article.list("tags") | The list at a field, creating an empty one if it is not set yet |
article.id | The asset's id |
article.descendants() | Every record inside this one, including itself |
Embedded objects and references
Brightspot stores an asset's related content in one of two ways, and which one a field uses is part of your content model rather than something you choose in the script.
An embedded object lives inside its parent and has no life of its own. Build one with Record and set it:
1const { Record } = dari;23const article = new Record("com.example.Article");45article.put("caption", new Record("com.example.Caption").put("text", "The harbor at dawn"));67article.put("gallery", [8new Record("com.example.Caption").put("text", "One"),9new Record("com.example.Caption").put("text", "Two")10]);
A reference points at an asset stored separately, which has to already exist. Point at one with RecordRef and its id:
1const { RecordRef } = dari;23article.put("author", new RecordRef(input.authorId));
Get it the wrong way round and the script tells you which one the field wants:
1Field "author" holds a reference to separately stored content, so it needs a RecordRef.2A record built in this script has nothing to point at yet—save it first, then reference it by id.
That message names the one real constraint here: a script cannot create an asset and point another field at it in the same step, because the asset does not exist until a Save step runs. Create it in one step, save it, then reference it by id in the next.
Editing an existing asset
Read an asset with a Content action's Fetch operation, then load it into the script:
1const { Record } = dari;23const article = Record.fromState(payload.contentValues);45article.get("headline"); // "Storm closes harbor"6article.get("gallery").map(s => s.get("text")); // ["One", "Two"]
Embedded objects come back as records you can change in place, and lists come back as ordinary arrays:
1article.put("headline", "Storm closes harbor early");23article.get("gallery")[0].put("text", "One, revised");45article.list("gallery").push(new Record("com.example.Caption").put("text", "Three"));
Saving what you changed
A Save step takes only what changed, so compare the asset before and after:
1const { Record, diff } = dari;23const article = Record.fromState(payload.contentValues);4article.put("headline", "Storm closes harbor early");56diff(payload.contentValues, article.toState()).byRecord;
Map that value into the Save operation's Differences field. It covers the asset and every embedded object inside it in one piece.
You do not have to track what you touched—diff works it out by comparing, and it is precise about where a change belongs. Changing a field on an embedded object reports a change on that object and none on its parent. Adding to a list reports both the parent's list and the new object. Changing nothing reports nothing, which isEmpty tells you, so a step can skip a pointless save:
1const changes = diff(payload.contentValues, article.toState());23({ changed: !changes.isEmpty, differences: changes.byRecord });
For an asset you are creating, there is nothing to compare against. Ask the record for all of it:
1const story = new Record("com.example.Story")2.put("headline", input.headline)3.put("lead", new Record("com.example.Caption").put("text", input.caption));45story.toDifferences().byRecord;
Pick the content type on the Save step itself; the script supplies everything else. The step fills in the new content's own _id and _type, so nothing in the script has to agree with them.
Fields that come from elsewhere
Some fields on your content are not declared by the content type itself. Brightspot adds publishing dates, import provenance, SEO settings and much else through modifications—separate classes that attach their fields to a type. Those fields are stored under prefixed names you would otherwise have to know.
as addresses them by the class and the plain field name instead:
1article.as("brightspot.importapi.ImportObjectModification")2.put("externalId", input.wireId)3.put("sourceUrl", input.wireUrl);45article.as("com.psddev.cms.db.Content$ObjectModification")6.put("publishDate", new Date());
You name the class and the field as they appear in the code; the prefix is worked out for you. A class that does not apply to your type lists the ones that do, and a field the class does not have lists its fields.
as supports the same calls as a record—get, put, putAll, remove and has—all reading and writing the same asset.
Locations and areas
1const { Location, Region } = dari;23venue.put("where", new Location(39.1498124, -76.848583));45venue.put("deliveryArea", Region.circle(new Location(39.0, -76.0), 5000));
Location takes a latitude and a longitude, in that order. Region.circle takes a centre and a radius in metres; Region.polygon takes a list of locations, and closes the shape for you. Add more than one with addCircle and addPolygon.
Reading an area back gives you centres and metres rather than the stored form:
1const area = Region.fromState(venue.get("deliveryArea"));23area.circleList[0].radiusInMeters; // 5000
Files
A script has no network access, so it cannot upload. It can point a file field at something the CMS can already reach:
1const { StorageItem } = dari;23article.put("leadImage", StorageItem.url(payload.cdnUrl)4.withContentType("image/png")5.withMetadata({ width: 800, height: 600 }));
A plain URL works as shorthand:
1article.put("leadImage", "https://cdn.example.com/pic.png");
To copy a file from one asset to another, read it and set it:
1target.put("leadImage", StorageItem.fromState(source.get("leadImage")));
Dates and times
Brightspot stores a moment in several different ways—an exact instant, a calendar date with no time, a time of day with no date, and more. A JavaScript Date works for all of them, because the field says which one it is:
1const when = new Date("2026-08-14T01:15:30Z");23article.putAll({4publishedOn: when, // an exact instant5runsOn: when, // a calendar date6startsAt: when, // a time of day7airsAt: when // a date and time with a zone8});
Choose the time zone
A calendar date depends on where you are. The instant above is 14 August in UTC and 13 August in New York, so set the zone the dates belong to before you convert any:
1dari.timeZone = "America/New_York";
It defaults to UTC and applies to every conversion from a Date in the script. Get this wrong on a publish date and an evening's content lands on the wrong day.
Building values directly
Where you have parts rather than a moment, or a length of time rather than a point in one, build the value:
1const { LocalDate, LocalTime, Year, Duration, Period } = dari;23article.putAll({4runsOn: LocalDate.of(2026, 8, 14),5startsAt: LocalTime.of(9, 30),6copyrightYear: Year.of(2026),7readingTime: Duration.of({ minutes: 4, seconds: 30 }),8retention: Period.of({ years: 1, months: 6 })9});
Duration.between(start, end) measures the gap between two dates. Every one of these also accepts a string, checked against the format the field expects:
1"14/08/2026" is not a valid LocalDate.
A length of time cannot be worked out from a single moment, and the script says so rather than guessing:
1Field "readingTime" is a duration, which is a span of time rather than a point in one,2so a Date cannot say what it should be. Use Duration.of({ ... }).
Rich text
A rich-text field holds the markup the rich-text editor produces—which is not quite HTML, and building it by hand is the main thing this API saves you from.
The editor separates paragraphs with two <br/> tags and a single line break with one—it never writes a <p> tag. paragraph and line write exactly that, so what a script produces is what the editor would have produced.
1const { RichText } = dari;23const body = new RichText()4.paragraph("Winds hit 60mph overnight.")5.paragraph(t => t6.text("The ")7.bold("harbor")8.text(" is closed until ")9.link("https://example.com/notice", "further notice")10.text("."))11.list(["Ferries cancelled", "Roads closed"]);1213article.put("body", body);
Text you pass in is escaped, so a value from an API or an AI step cannot break the markup. Use html when you have markup you want written as-is.
Text that was never meant for a rich-text field
Prose that arrived from somewhere with no idea where it was going—an AI step's answer, an imported description—separates its paragraphs with blank lines. Assigning that string to a rich-text field stores something the editor never writes: the newlines aren't markup, so it renders as one run-on paragraph, and any < or & in it becomes markup the moment it's stored.
fromPlainText is that conversion:
1article.put("body", RichText.fromPlainText(input.body));
Blank lines become paragraph breaks, single newlines become soft line breaks, and the text is escaped. It returns a RichText, so you can keep building on it if there's more to add.
| Call | Produces |
|---|---|
paragraph(text) | A block of text, separated from its neighbours by <br/><br/> |
line(text) | A line inside the current block, ending in a single <br/> |
list(items) / orderedList(items) | A bulleted or numbered list |
align(position, text) | Text aligned left, center or right |
element(tagName, options) | One of your CMS's rich-text elements |
html(markup) | Markup written unchanged |
fromPlainText(text) | Plain prose converted, as above (static) |
Inside paragraph, line, list and align, pass a function to mix formatting: text, bold, italic, underline, strikethrough, code, link, element and html.
Rich-text elements
Anything richer than text and lists—a pull quote, an embedded image, an iframe—is a rich-text element your CMS defines. Add one by its tag name, giving it the attributes and body it expects:
1body.element("bsp-pull-quote", {2attributes: { "data-align": "right", "data-attribution": "Dan" },3body: "Nothing like it in thirty years."4});
Each element decides for itself where its data goes, and that decision lives in the element's Java code, which cannot run inside a script. Some elements keep each field in its own attribute, some use the body, and some put their whole configuration in a single attribute as serialized state. So you write the attributes and body that element expects, rather than setting fields on it and hoping.
For the last kind, give the attribute a record and it is written as that record's state—which can hold a reference like any other field:
1const { Record, RecordRef } = dari;23body.element("bsp-image", {4attributes: {5"data-state": new Record("com.example.ImageEnhancement")6.put("image", new RecordRef(input.imageId))7.put("caption", "The harbor at dawn")8}9});
The attribute name is the element's choice—data-state is a common one, not a rule.
Which elements a field allows depends on your CMS and on that field's toolbar. Ask a developer on your team which tag names apply, which attributes each expects, and whether it reads its body.
Reading and changing existing rich text
1const body = RichText.from(article.get("body"));23body.toPlainText(); // the readable text, markup removed4body.isEmpty;5body.elements("bsp-pull-quote"); // every pull quote in the body
rewrite visits every occurrence of one element. Return the element to keep it, or null to remove it:
1body.rewrite("bsp-pull-quote", el => {2if (el.attribute("data-align") === "left") {3return null;4}5el.attribute("data-align", "center");6return el;7});89article.put("body", body);
An element you are given exposes exactly what is in the markup—tagName, body, and attribute(name). Where an attribute holds serialized state, record(name) reads it back as a record:
1body.rewrite("bsp-image", el => {2const state = el.record("data-state");3state.put("caption", state.get("caption").toUpperCase());4return el.attribute("data-state", state);5});
Everything rewrite does not visit is left exactly as it was, so a script that changes one pull quote cannot disturb the rest of the body.
When something is wrong
Every complaint from dari names the field and what it expected. They stop the step with the message in the run's log, so a mistake surfaces at the step that made it.
To handle one yourself rather than fail the step, catch it:
1try {2article.put(name, value);3} catch (error) {4if (error instanceof dari.DariError) {5console.warn("skipping " + name + ": " + error.message);6} else {7throw error;8}9}
console.log, console.warn and console.error write to the step's execution log, which is the quickest way to see what a value holds mid-script.
Returning a value
A script has no return statement. The value of its last expression is the step's output, and writing return at the top level is a syntax error. End the script with the expression itself:
1article.toState();
Wrap an object in parentheses, or it reads as a block rather than a value:
1({ changed: true, differences: changes.byRecord });
Give the step plain values—what toState, toDifferences and diff produce—rather than the dari objects themselves:
1article.toState(); // yes2changes.byRecord; // yes3article; // no
Making it pickable downstream
A later step—a Save operation's Differences field, most often—reaches your result through the variable picker, and the picker lists what the step's Output Type says it holds. Leave it unset and there is nothing to list.
A differences map is keyed by the record ids of the run that produced it, so its keys are not something you can write down in advance. Declare it as an Object with no fields: that says a value comes back without claiming to know what is inside it, and the picker offers the whole thing under payload.
Returning the map on its own is the shortest route:
1changes.byRecord;
Wrapping it is usually worth the extra line, because it gives you something to branch on. Declare an Object with two fields—changed as a Boolean, differences as an Object with no fields—and both are pickable by name:
1({ changed: !changes.isEmpty, differences: changes.byRecord });
An Expression condition can then skip the save when nothing changed, and the Save step binds differences directly.
Auto-generate Output Type is the wrong tool here. It infers the shape from the last run's result, and for a differences map that means writing this run's record ids into the declaration as though they were field names. Declare the Object by hand instead.
A complete example
Reading an article, tidying every caption in its gallery, stamping where it came from, and saving only what changed:
1const { Record, diff } = dari;23dari.timeZone = "America/New_York";45const article = Record.fromState(payload.contentValues);67for (const slide of article.get("gallery") || []) {8const text = slide.get("text");9if (text) {10slide.put("text", text.trim().replace(/\s+/g, " "));11}12}1314article.as("brightspot.importapi.ImportObjectModification")15.put("externalId", input.wireId);1617const changes = diff(payload.contentValues, article.toState());1819({ changed: !changes.isEmpty, differences: changes.byRecord });
Reusing content if it exists, creating it if it doesn't
The common shape—use the Tag that already exists, or make one; update the Article with this slug, or import it. It runs as a straight line, with no branch in the automation:
- A Content Fetch looks the content up by its unique field: Lookup Field
slug, Lookup Value the slug you have, and a Content Type to search within. Finding nothing is a normal result there, not a failure. - This script builds from what came back, either way.
- A Content action → Save with the same Content Type and Create If Missing on, and no target.
1const { Record } = dari;23const article = payload.found4? Record.fromState(payload.contentValues)5: new Record("com.example.Article");67article.put("slug", input.slug);8article.put("headline", input.headline);910article.toDifferences().byRecord;
Record.fromState keeps the id the content already has, and new Record(...) mints one, so the map you hand the Save names the right content in both cases. The Save reads that id and decides for itself whether it is updating or creating—nothing upstream has to know which.
Use toDifferences() here, not diff(...). A diff of a record against itself is empty, and an empty map names no content for the Save to act on. Handing over the whole record keeps the id present even on a run that changes nothing. That run succeeds: an upsert asks for content to exist in a given state, and it already does.
Limits
A Content Script runs under the same sandbox and the same ceilings as a Script action—no file system, no network, and bounds on time, memory and statements. The dari object's own work counts toward them, so a script that assembles a very large content tree does more than its line count suggests.
Reading the content model costs one call to the connected CMS the first time a script names a type. That result is cached, so a script naming the same types on every run pays for it once rather than every time.