Guide version __APP_VERSION__ · document format bento/slides (v1). This
guide matches the bento/slides shell of the same version. A deck's #bento-doc
JSON is always the source of truth — if it was written by a newer shell it may
carry features beyond this guide; unknown keys are ignored, never fatal.
Bento is a suite. Slides is the first app; Spaces (
bento/spaces, notes and wiki) ships alongside it. Dash (bento/dash, data and sheets) is in development, and a word processor is planned. Each ships as its own self-contained distributable —Bento_Slides.bento.html,Bento_Spaces.bento.html, and so on — with its own agent guide atbento.page/<app>/agents.md. This guide covers Slides only. Before you edit a file, check its"format"field and use the matching guide; if the format is one you have no guide for, don't guess at its shape.
Drop this file into your context (or point your harness at it) and you can
author and edit Bento presentations directly. Also published at
bento.page/agents.md. For Claude Code,
install the packaged bento-slides skill once and it triggers automatically
(or via /bento-slides) — it can even download the latest Bento app itself,
so a deck can be authored from an empty folder:
/plugin marketplace add nyblnet/bento
/plugin install bento-slides@bento
…or as a plain personal skill:
mkdir -p ~/.claude/skills/bento-slides && curl -fsSL https://bento.page/skills/bento-slides/SKILL.md -o ~/.claude/skills/bento-slides/SKILL.md(claude.ai / Claude Desktop: upload bento.page/skills/bento-slides.zip under Settings → Skills.)
Working without the skill, from an empty folder? Download the app itself — this is the file you write your document into:
curl -fsSL https://bento.page/releases/slides/Bento_Slides.bento.html -o "<Topic>.bento.html"The downloaded file's #bento-doc block is empty. That is expected: opened
in a browser it mints a fresh showcase deck to get a new user started, but on
disk there is nothing to discard and nothing to copy from. Write your document
into the empty block.
A Bento deck (*.bento.html) is a self-contained HTML file. The document
lives in ONE plaintext block near the top:
<script type="application/bento+json" id="bento-doc">
{ "format": "bento/slides", ... }
</script>If a deck has live collaboration switched on, its
#bento-docblock contains the credentials to that session — the room key, and depending on the copy, a writer or owner private key. That is deliberate and it is what makes sharing work without accounts: the file is the invitation, so opening a copy joins the room.The consequence is easy to miss, because nothing about a document looks like a credential. Anything that receives the file receives the room: a chat, a ticket, a harness, a model provider's logs. Not because anything is broken — that is simply what the file is.
So, before you take a deck into your context:
- Look for a
"collab"key in the document. Nocollab, or acollabwith noownerPriv/writerPriv/invite, and there is nothing to leak.- If those are present, say so before continuing. The person may not know their deck is live, and they are the only one who can decide.
- Prefer a copy that carries no keys: Save ▾ → Save read-only copy… or Share → Stop sharing on a duplicate. In the browser,
window.bento.validate()reports this ascollab-secrets-present.Editing the JSON in place is fine and does not change any of this — the exposure is in reading the file, not writing it. And note that removing the keys after the fact does not retract them: if a shared deck has already gone somewhere, the remedy is Share → Rotate keys, which revokes the old room.
Two ways to work with it:
- File harness (Claude Code, agent sandboxes): edit the JSON inside the
#bento-docblock in place. Escape every<in the JSON as\u003cso the block can never contain a literal</script>. Leave everything else in the file untouched. - Chat round-trip (any chatbot): the user copies the JSON out via
Save → Copy document JSON, you return a full replacement document,
they paste it back via Save → Replace from JSON… (undoable).
In the browser console:
window.bento.doc(read) /window.bento.loadDoc(json)(write, undoable).
Read this section first — it is the difference between a wall of text and a Bento deck. The format's whole value is motion, morph, charts and interactivity. A correct-but-static result (bullets on slides) wastes it and is the #1 failure mode. The move is to look at the source material and map each kind of content to the feature built for it:
| When the material is… | Reach for | Why |
|---|---|---|
| numbers to compare visually (trend, magnitude, share) | a chart element | bars/lines read instantly |
| a comparison / spec / pricing / feature grid (rows × columns of labels + values) | a table element | structured cells beat 20 hand-placed text boxes; it styles cohesively |
| consecutive slides about the same thing changing (before/after, process steps, a metric across stages) | morph: same element id on both slides + transition:"morph" on the later one |
the shared elements glide; this is Bento's signature and is almost always missed |
| a point to drill into (a definition, "click to see how", a sub-topic) | a state slide (stateOf + element link) |
keeps the linear story clean; the detail is one click away |
| a hero / full-slide image | full-bleed image + scrim rect + text, with ken-burns | static photos feel dead; a slow drift feels intentional |
| a sequence / flow / timeline / connection | a line or path with a dash-march loop, or morph a highlight through the steps |
motion carries the eye along the sequence |
| a headline number | big text + fx:{countUp:true} |
the count-up earns attention |
| every cover / section divider | at least one ambient motion (ken-burns, an orbiting accent) | a still cover is a missed first impression |
| repeated chrome / a logo | keep its id stable across slides |
it morphs in place instead of popping on every slide |
| a demo clip / recording / soundbite | a media element (embed short, link long) | a live video/audio beats a screenshot of one |
Morph a title + accent bar between two slides — identical ids, transition:"morph":
// slide 1
{ "id":"s1","transition":"none","elements":[
{ "id":"headline","type":"text","x":96,"y":140,"w":900,"h":200,"html":"Big claim.","fontSize":120,"fontWeight":900,"color":"#111","align":"left","valign":"top","lineHeight":1,"rotation":0,"opacity":1 },
{ "id":"bar","type":"shape","shape":"rect","x":96,"y":380,"w":320,"h":16,"fill":"#E8442E","stroke":"none","strokeWidth":0,"radius":0,"rotation":0,"opacity":1 } ] }
// slide 2 — same ids, new frames → they animate
{ "id":"s2","transition":"morph","elements":[
{ "id":"headline","type":"text","x":96,"y":84,"w":500,"h":80,"html":"Big claim.","fontSize":40,"fontWeight":900,"color":"#888","align":"left","valign":"top","lineHeight":1,"rotation":0,"opacity":1 },
{ "id":"bar","type":"shape","shape":"rect","x":96,"y":170,"w":16,"h":450,"fill":"#E8442E","stroke":"none","strokeWidth":0,"radius":0,"rotation":0,"opacity":1 } ] }A bar chart from a table — bar/line data is PLAIN NUMBERS (see chart rules below):
{ "id":"c1","type":"chart","x":96,"y":260,"w":1088,"h":380,"rotation":0,"opacity":1,"preset":"bar","option":{
"xAxis":{"type":"category","data":["2022","2023","2024","2025"]},
"yAxis":{"type":"value"},
"series":[{"type":"bar","data":[420,780,1300,2450],"itemStyle":{"color":"#141310"},"barWidth":90}],
"tooltip":{"trigger":"item","formatter":"{b}: {c}"} },
"fx":{"enter":"fade-up"} }A comparison table — a real HTML table; cells are the same inline-html subset as text:
{ "id":"tbl1","type":"table","x":240,"y":220,"w":800,"h":260,"rotation":0,"opacity":1,
"header":true,
"columns":[{"w":1.4},{"w":1},{"w":1}],
"rows":[
{ "cells":[{"html":"Plan"},{"html":"Price","align":"right"},{"html":"Seats","align":"right"}] },
{ "cells":[{"html":"Team"},{"html":"$29"},{"html":"5"}] },
{ "cells":[{"html":"Business"},{"html":"$79"},{"html":"25"}] } ],
"style":{"headerBg":"#1E2A3A","headerColor":"#fff","zebra":"rgba(30,42,58,0.05)",
"borderColor":"rgba(30,42,58,0.14)","borderWidth":1,"cellPadX":16,"cellPadY":11,
"fontSize":18,"color":"#1E2A3A","radius":10} }A state slide reached by clicking a node — parent slide has the clickable element, the state lives adjacent:
// on the parent slide, an element the viewer clicks:
{ "id":"node-ingest","type":"shape","shape":"ellipse","x":330,"y":180,"w":74,"h":74,"fill":"#0B0E1E","stroke":"#7A5CFF","strokeWidth":2,"radius":0,"rotation":0,"opacity":1,"link":"state-ingest" }
// a hidden state slide (arrow keys skip it; ← returns to parent):
{ "id":"state-ingest","stateOf":"parent-slide-id","transition":"morph","name":"INGEST","elements":[ /* … */
{ "id":"dismiss","type":"shape","shape":"rect","x":0,"y":0,"w":1280,"h":720,"fill":"rgba(0,0,0,0)","stroke":"none","strokeWidth":0,"radius":0,"rotation":0,"opacity":1,"link":"parent-slide-id" } ] }Full-bleed hero image with ken-burns + scrim + text:
{ "id":"photo","type":"image","x":0,"y":0,"w":1280,"h":720,"src":"asset:hero","fit":"cover","radius":0,"rotation":0,"opacity":1,"fx":{"ambient":"kenburns","ken":{"dir":"drift","scale":1.09,"duration":22}} },
{ "id":"scrim","type":"shape","shape":"rect","x":0,"y":0,"w":1280,"h":720,"fill":"rgba(10,14,26,0.55)","stroke":"none","strokeWidth":0,"radius":0,"rotation":0,"opacity":1 },
{ "id":"htitle","type":"text","x":96,"y":460,"w":1000,"h":180,"html":"On top of the photo.","fontSize":76,"fontWeight":800,"color":"#fff","align":"left","valign":"top","lineHeight":1.05,"rotation":0,"opacity":1,"fx":{"enter":"fade-up"} }(Embed the image as a data URI in doc.assets under key hero, then reference "asset:hero" — the file must stay self-contained.)
Video — embed a short clip, or link a big one (autoplay is present-only; muted required to autoplay):
// embedded — self-contained, keep it small (a few MB at most):
{ "id":"clip","type":"media","kind":"video","src":"data:video/mp4;base64,AAAA…","x":220,"y":120,"w":840,"h":472,"rotation":0,"opacity":1,"controls":true,"muted":true,"autoplay":true,"loop":true,"fit":"cover","radius":8 }
// linked — deck stays tiny; needs the URL at play time (give it a poster):
{ "id":"clip","type":"media","kind":"video","src":"https://cdn.example.com/demo.mp4","poster":"asset:demo-poster","x":220,"y":120,"w":840,"h":472,"rotation":0,"opacity":1,"controls":true }- Any numbers rendered as text that should be a chart?
- Do consecutive slides on one subject share element ids +
transition:"morph"? - At least one motion moment (ken-burns / loop / count-up), especially the cover?
- A drill-down that would work better as a state slide?
- One accent colour, at most two typefaces, 96px side margins (right-most x ≤ 1184)?
- Speaker notes written on each slide (they travel in the file and double as the talk track)?
- Have you actually looked at it? Open the deck and page through every slide. Text overflowing its box, two elements crowding each other, a heading that wrapped to three lines, a chart key that was silently dropped — none of these are visible in the JSON, and all of them are obvious on screen. This is the only check that catches what the others cannot; a deck nobody rendered is not finished.
The format is absolute pixels, which is what lets morph, the drag handles and the renderer work from one representation. The cost falls on you: the height of a string at a given width and font is not knowable from the JSON. Stop guessing and ask:
window.bento.measure({ html: 'Long paragraph…', w: 600, fontSize: 28, lineHeight: 1.4 })
// → { height: 236, width: 600, lines: 6 }Pass a spec to size text before the element exists — which is the point, as
it lets you lay a slide out correctly the first time. Pass an element id to
measure one already in the deck, and include h in a spec to get fits and
overflow back too. It renders through the real renderer, so the answer is
what the slide will actually do, not an estimate.
Use it for the arithmetic that used to be guesswork: stacking cards in a column, deciding whether a heading needs two lines or three, sizing a caption under a photo. In the editor, the same thing is a Fit height to text button in the Typography panel.
Open the deck and run it in the browser console. It reports, in one pass, the things the runtime otherwise swallows in silence:
const { ok, counts, findings } = window.bento.validate()
findings.filter(f => f.severity !== 'info')Each finding is {code, severity, message, slide?, element?, path?}. It checks
unknown property names (a typo is ignored, so the styling just never applies),
text that overflows its box (measured against the real renderer), elements off
the canvas, entrances that can never run, dash-march without a dashed stroke,
broken link and asset: references, duplicate ids and morph-key collisions,
and chart options charts-lite does not implement.
It only reads — it never changes the document, and a finding is advice, not a
refusal. severity: "info" is deliberately quiet (a photo bleeding off the
canvas is a design move, not a defect); error means something is broken, like
a link to a slide that does not exist.
This does not replace looking at the deck. It catches what is checkable; the rest — whether a slide is any good — still needs eyes.
Start from this skeleton when creating a deck from scratch. size and
theme (including fontFamily) are required — the app will not boot
without them — and elements should carry the full field set shown.
{
"format": "bento/slides", "version": 1, "title": "My deck",
"size": { "width": 1280, "height": 720 },
"theme": { "background": "#101418", "color": "#F2F0EA",
"accent": "#FF9E8A", "fontFamily": "system-ui, sans-serif" },
"slides": [
{ "id": "s1", "background": "#101418", "transition": "none",
"notes": "speaker notes here",
"elements": [
{ "id": "t1", "type": "text", "x": 96, "y": 260, "w": 1088, "h": 160,
"rotation": 0, "opacity": 1,
"html": "Hello from an agent.",
"fontSize": 88, "fontFamily": "system-ui, sans-serif",
"fontWeight": 800, "color": "#F2F0EA",
"align": "left", "valign": "top", "lineHeight": 1.1 }
] }
]
}-
text:
html(inline<b> <i> <br>ok),fontSize,fontFamily,fontWeight,color,align(left|center|right),valign,lineHeight, optionalletterSpacing. -
shape:
shape=rect|ellipse|triangle|arrow|line|path,fill,stroke,strokeWidth,radius(rect corner). OptionalfillGradient{angle, stops:[{at:0..1, color}]}(CSS-convention angle). Lines take their color fromfilland draw horizontally across the box (rotate for vertical);strokeStyle: solid|dashed|dotted; tipslineStart/lineEnd=arrow|dot|bar. Apathis a free vector:d(SVG path data) +pathBox[x,y,w,h]authoring viewBox, stretched into the element box; for a curved line setfill:"transparent"+ astroke+strokeWidth. A connector is aline(orpath) withfrom/to: {el, side}— its ends follow those elements and re-route when they move (side"auto"picks the nearest border). Make sure a shape's colour contrasts with its slide background. -
image:
src= data URI or"asset:<key>"intodoc.assets,fit: cover|contain|fill,radius. Embed images as data URIs indoc.assetsand reference them — the file must stay self-contained. -
chart:
preset: bar|line|pie|scatter,option= ECharts-SHAPED pure JSON. Bar/line series data must be plain numbers ({value,itemStyle}objects coerce to 0 — only pie takes{name,value}); per-item bar colors are unsupported, color by series; template formatters only ({b},{c},{d}), never functions. Dual axis: for two series on very different scales (e.g. volume + a %), makeyAxisan ARRAY of two{type:"value"}axes (give the 2ndaxisLabel:{formatter:"{value}%"}) and point the odd series at it with"yAxisIndex":1— render it as alineover the bars. The engine is charts-lite, not ECharts — it reads the option SHAPE and ignores every key it does not implement, silently. What it honours:- top level —
color,series,xAxis,yAxis,legend,grid,tooltip,textStyle,dataZoom - any series —
type,name,data,yAxisIndex,itemStyle.color - bar —
itemStyle.borderRadius - line —
smooth,symbol,symbolSize,lineStyle.color,lineStyle.width,areaStyle.color - pie —
radius,label.formatter(orlabel:false),itemStyle.borderColor,itemStyle.borderWidth - axes —
type,data,min,max,axisLabel(fontSize,fontWeight,color,formatter),axisLine,splitLine - legend —
show,top,bottom,textStyle.fontSize,textStyle.fontWeight
labelon a bar or line series does nothing — value labels above bars are pie-only. If you need the numbers visible on a cartesian chart, put them in a table beside it, or use text elements. - top level —
-
table:
columns(array of{w}fractional weights),rows(array of{cells:[{html, align?, color?, bg?, bold?}]}),header(bool — row 0 is the header), and astyleobject (headerBg,headerColor,zebra?,borderColor,borderWidth,cellPadX,cellPadY,fontSize,color,radius). Renders as a real HTML table. Use for comparison/spec/pricing grids — NOT for numeric trends (use a chart). -
svg:
assetormarkupfor static artwork. Prefer composing rects/ texts/paths — those stay editable and can morph. -
media:
kind: video|audio,src= data URI (embedded — travels in the file), an external URL / relative path (referenced — keeps the file small, needs the network at play time), or"asset:<key>". Video also takesposter,fit: cover|contain|fill,radius. Playback flags:controls,autoplay,loop,muted. Autoplay fires only in present mode, and browsers requiremuted:truefor a video to autoplay. Embed only SHORT clips — a big data URI bloats the file and makes it slow to open/save; host large media and reference its URL instead.
-
Morph = shared ids. Slides with
"transition": "morph"tween any elements whoseidmatches the previous slide — position, size, color, gradients. This is THE signature move: carry 2–4 ids through the deck and rearrange them per slide. Generators must emit deterministic ids. -
morphIddecouples morph identity fromid. The real pairing key ismorphId || id, so an element can keep whateveridit likes and set"morphId": "running-head"to morph against a differently-named element on the next slide. For a generator this beats threading one id by hand through every slide, and it lets two independently-created elements pair up. The key must be unique within a slide. Plain sharedidstill works and is still the simplest thing when you control both slides. -
Entrances:
fx: { enter: "fade-up", order: 0 }— equalorder= simultaneous. On a morph arrival the rule is per element, and it turns on whether that element has a morph partner on the previous slide:- has a partner → it morphs, and
fx.enterandfx.countUpare both skipped. It is already in motion and already showing its number; an entrance would fight the tween and a count-up would restart from zero. - no partner → it is new to the slide, so both run normally. Without an
fx.enterit gets an automatic fade-and-rise so nothing ever just pops in.
So a headline number, or a panel that sweeps in from the right, is fine on a morph slide — just make sure it is new to that slide.
- has a partner → it morphs, and
-
Ken-burns:
fx: { ambient: "kenburns", ken: { dir: "drift|out|in", scale: 1.08, duration: 20 } }—driftloops,out/insettle once on slide entry. For full-bleed photos: image at 0,0,1280,720 + a scrim rect- text on top. Never combine entrance tweens with motion-path loops.
-
Loops: two shapes, both under
fx.loop.{ type: "dash-march", distance: 18, duration: 1.4 }— marches the stroke dashes along a shape. It animatesstrokeDashoffset, so it needs astrokeand a dash pattern: setstrokeStyle: "dashed"or"dotted". On a solid stroke the tween still runs and there is nothing to see.{ type: "motion-path", path: "M0,0 C60,-40 140,40 200,0", duration: 6, delay: 0, ease: "none", speeds: [1, 1] }— drifts the element along a path given RELATIVE to its resting position (the first anchor is where it sits).speedsis optional, one multiplier per on-curve point, and lets the element dwell in places and rush others; omit it for constant pace. Never put an entrance tween on a motion-path element — they fight over the same transform.
-
Interactivity: element
link: "<slide-id>"jumps on click; a slide withstateOf: "<parent-id>"is a hidden variant reached only by links (arrow keys skip it, ← returns to parent). Give clickable things a padded transparent rect as the hit target, not the text itself. -
Hidden slides:
"hidden": truekeeps a slide in the deck and out of the show — arrow keys skip it, PDF export leaves it out, and it is never the file's thumbnail — but an elementlinkstill reaches it. That is what it is for: backup and appendix material you jump to only if asked. By default a hidden slide does not consume a page number either, so{{page}}/{{pages}}stay contiguous for the audience; setpresent.numberHidden: truefor the office-suite behaviour where it keeps its number.Not the same as
stateOf. A state is a variant OF another slide (← returns to its parent, and it morphs with it); hidden carries no such relationship. Use a state for "click to drill into this", hidden for "only if they ask". -
Numbers count up with
fx: { countUp: true }. -
Speaker notes (
notes) are part of the document — write them; they make a template teach itself.
-
Canonical canvas 1280×720 (
doc.sizecan differ — read it first). -
Keep 96 px side margins (right-most content x ≤ 1184).
-
Column arithmetic, already done. On 1280×720 inside 96 px margins the content band is 1088 px wide. Use these rather than computing your own:
Split Width xpositionsGutter 2 columns 528 96, 656 32 3 columns 340 96, 470, 844 34 4 columns 254 96, 374, 652, 930 24 60 / 40 (text + image) 624 / 432 96, 752 32 Every row ends flush at x = 1184. Vertically, a title band of
y:72 h:84over content starting aty:208leaves 416 px of content height above a 96 px bottom margin. -
One accent color; 2 typefaces max.
themesets deck defaults. -
Fonts:
doc.fonts({family, asset, weight}) + woff2 data URIs indoc.assetsif you need embedded faces; otherwise stick to system stacks. AfontFamilynaming a face the document does not carry falls back silently to the next entry in the stack — there is no warning, and worse, it will usually look right to you, because you are the one with the typeface installed. Everyone else gets the fallback.validate()reports this (font-not-embedded) precisely because you cannot see it locally. Fonts belong to the DOCUMENT, not the app: Instrument Sans and Fraunces appear in the starter deck and in several templates because those files embed them in their owndoc.assets, not because the app provides them. So either embed the woff2 yourself, start from a template that already carries the face, or name a system stack and mean it. Always write a full stack ("'Fraunces', Georgia, serif"), never a bare family name.
doc.layouts is a supported top-level key: an array of Slide-shaped templates
the editor offers under Apply layout. Every deck also gets five built-ins
(layout-title, layout-title-content, layout-two-col, layout-section,
layout-blank), which are scaled to the deck's doc.size when applied.
The part that matters when you are generating a deck is role. Any text
element can carry "role": "title" | "subtitle" | "body" | "kicker". Applying a
layout matches donor to target by id first and then by role + type, so
roles are what let someone restyle your deck later without re-typing it —
content rides across, the layout supplies frame and typography. Setting them
costs one key per element and makes a generated deck feel native to the editor.
Two smaller things: a layout's text elements use placeholder (a dimmed prompt
shown in the editor, hidden in present and print) rather than html, and
slides instantiated from the same layout keep their element ids — which is
exactly why their furniture morphs across a transition.
Put these tokens in any text element's html; they resolve at render time (the
model keeps the raw token, so numbering/props update automatically):
{{page}}, {{pages}} (position among non-state slides; zero-pad with
{{page:2}}→"06"), {{title}}, {{date}}, {{time}}, and the document
properties {{author}}, {{company}}, {{subject}}, {{event}}. Set the
props in an optional top-level "meta": {author, company, subject, event, keywords} object — great for title slides and footers that fill from one place.
- Escape
<as\u003canywhere in the JSON when writing the file block. - Don't invent property names — unknown keys are ignored, so a typo means your styling silently doesn't apply.
docIdis the document's identity — never regenerate it when editing.readonly: truemakes a PLAYER file — it boots straight into the presentation with no editor. Set it only on hand-out copies.- If
template: trueis set, every open mints a fresh document (that's for distributable templates; remove it for a personal deck). - Charts degrade gracefully but anything outside the list in the chart element type above is ignored, with no warning — keep options minimal and check the rendered slide rather than trusting the JSON.
- Media size: embedding a large video as a data URI can push the file into
the tens of MB and make it slow to open and save. Embed only short clips;
otherwise host the file and put its URL in
media.src.
Working examples of everything above: the template decks at bento.page — open one and read its JSON block.