article
Run Kotlin in a blog post with a Storyblok blok and one CSP origin
TL;DR A Kotlin snippet in a post can run and be edited in the reader's browser for 1527 bytes of JavaScript (978 gzipped) and one origin in connect-src. The compiler API answers HTTP 200 for a clean run, a compile error and an exception, so read the response body, not the status.
A compile error and a crash both come back as HTTP 200, with the failure buried in the JSON body under errors and exception. A status check would report both as a success.
A blok is a component plus a whitelist entry
Posts should teach by letting the reader do something, and for a Kotlin blog that means running the code. Claude Code wrote the component, the schema calls and the tests; I decided on the blok, require a schema backup before any change and drafts only, and I deploy and publish.
Six steps take the blok from nothing to a rendered post.
From schema to page
The Article schema goes to a local JSON file before anything changes.
Create Runnable in Storyblok: a title, a code textarea and a target.
Add Runnable to the allowed components of the content field.
Read both schemas back and compare them with the intended JSON. Repeat until 0 differences.
Write Runnable.astro and register it in the components map.
Teach the markdown converter :::runnable so posts can use it.
Steps 2 to 4 run through the Storyblok MCP, a hosted server that wraps the Management API. Every operation is search, then describe, then one of the execute tools.
In the schema, a blok in a richtext field needs the component itself and a name in the field's component_whitelist. Storyblok documents the list as "an array of nestable or universal type block names allowed inside Blocks, Link, and Richtext fields". The component has three fields:
{
"title": { "type": "text" },
"code": { "type": "textarea", "required": true, "display_name": "Kotlin code" },
"target": { "type": "option", "default_value": "jvm",
"options": [{ "name": "JVM", "value": "jvm" }] }
}The target field has one option, JVM, because that is what the compiler request uses. The content field then got one new name:
"restrict_components": true,
"component_whitelist": ["Figure", "Callout", "CodeTabs", "Steps", "Terminal",
"EdgeProbe", "RequestFlow", "PromptCard", "Chart", "Compare", "Timeline", "Runnable"]Every schema write gets a read-back, diffed against the JSON that was meant to be sent. The backup (article-schema-before-<change>.json) exists before the first write.
Article readback vs intended (ids included): diffs = 0 Runnable readback vs intended (ignoring generated field ids): diffs = 0 Article readback vs backup: changed fields = ['content'] whitelist added = ['Runnable'] removed = []
Only content changed against the backup, and the only difference inside it is the new name.
One fetch and one CSP origin
The blok sends the code to JetBrains' public compiler server with a plain fetch after the reader clicks Run. Nothing is requested before the click. The request shape comes from the kotlin-playground source: a POST with JSON {args, files: [{name, text, publicId}], confType}. The compiler server's CORS setting defaults to *. A preflight from the blog's origin gets:
access-control-allow-origin: *
access-control-allow-methods: *
access-control-allow-headers: *Two alternatives lose on paper, before any code. A self-hosted kotlin-playground would still call the same API, so it needs the same connect-src entry plus a much larger script. Allowing a third-party script origin would weaken script-src, which is 'self' today.
On the Astro side, registration is one import and one entry in the integration's components map:
import Runnable from './storyblok/Runnable.astro';
// ...
components: { Figure, Callout, CodeTabs, Steps, Terminal, EdgeProbe, RequestFlow, PromptCard, Chart, Compare, Timeline, Runnable, ComposeDemo },The markdown side is one directive in the converter, which fails loudly if the block has no code:
if (name === 'runnable') { const f = lastFence(body.trim());
if (f.code === undefined) throw new Error('runnable needs one fenced kotlin block');
return blok({ component: 'Runnable', title: a.title, code: f.code, target: a.target ?? 'jvm' }); }Without JavaScript the reader still gets the title and the highlighted code. With JavaScript, a Run button appears. The policy needed one new origin and nothing else, and the middleware keeps it as a named constant with a comment that points at the two sources above:
- connect-src 'self'
+ connect-src 'self' https://api.kotlinlang.orgThe blok's script is 1527 bytes (978 gzip).
Status 200 for everything the compiler says
A successful run, a compile error and a runtime exception all come back as HTTP 200. The difference is in the body.
⏺ success {"errors":{"File.kt":[]},"exception":null,"jvmByteCode":null,"text":"<outStream>hi\n</outStream>"} time_total=1.042107 http=200 ⏺ compile error {"errors":{"File.kt":[{"interval":{"start":{"line":0,"ch":21},"end":{"line":0,"ch":22}},"message":"Unresolved reference 'x'.","severity":"ERROR","className":"File"}]},"exception":null,"jvmByteCode":null,"text":""} time_total=0.413751 http=200 ⏺ runtime exception {"errors":{"File.kt":[]},"exception":{"message":"boom","fullName":"java.lang.IllegalStateException", ...},"jvmByteCode":null,"text":""} time_total=0.975076 http=200
The component reads three places: errors["File.kt"] for compiler messages (line and column are 0-based), text for the program's output, and exception for a throw. In text, standard output and error come as <outStream> and <errStream> tags, interleaved in order. A run takes 0.3 to 1.4 seconds. The relevant part of the component:
for (const e of d.errors?.['File.kt'] ?? []) line(e.severity === 'ERROR' ? 'err' : '', `${e.severity} line ${e.interval.start.line + 1}:${e.interval.start.ch + 1}: ${e.message}\n`);
for (const m of (d.text ?? '').matchAll(/<(outStream|errStream)>([\s\S]*?)<\/\1>/g)) line(m[1] === 'errStream' ? 'err' : '', m[2]);
const x = d.exception;
if (x) line('err', `${x.fullName}: ${x.message ?? ''}\n`);The code is written to the DOM with textContent, so whatever the program prints stays text and never becomes markup. The real component also appends the line number of the first File.kt frame to the exception, which the excerpt leaves out.
An exception arrives together with the output that came before it, so a UI that treats the throw as a failure would hide the lines that explain it. This snippet prints twice and then fails. Before you press Run, decide which exception it ends with and which lines come first.
Prints, then throws
fun main() {
val xs = listOf(1, 2, 3)
println(xs.map { it * 2 })
println("sum = ${xs.sum()}")
println(xs[3])
}Then change xs[3] to xs[2] and run it again. The first two lines do not change, only the ending does.
Try it yourself
Paste this into Claude Code to add a blok to your own Storyblok space and Astro site:
Add an interactive blok with the Storyblok MCP
Add a new interactive blok called <BlokName> to my Storyblok space and render it in my Astro site. Work in the current repo and the Storyblok MCP (space <space_id>, region <region>). The blok has these fields: <fields>. It must be allowed in the richtext field <richtext field> of the content type <content type>.
Rules that always apply:
- Storyblok MCP: call search, then describe, before every operation, and pass `fields` to keep responses small. Never print a token or preview_token.
- Do not publish, unpublish or delete anything. Do not deploy.
- Stop and ask me before any schema change other than the two below.
Steps. Finish each one and show the result before the next:
1. Inspect: read the existing components (nestable or not, display names, preview_field) and the schema of <content type>. Write nothing yet.
2. Back up the schema of <content type> to a JSON file in the repo before any write. Show me the path, then stop and wait for my go before any write.
3. Create the component <BlokName> as nestable, not root, with the fields <fields>.
4. Add <BlokName> to component_whitelist of <richtext field> in <content type>. Change nothing else: send every other field exactly as in the backup.
5. Read both schemas back and diff them against the backup and your intended JSON. Only the whitelist may differ. Fix and repeat up to 3 times, then stop and tell me.
6. Astro: write src/storyblok/<BlokName>.astro and register it in the components map of the @storyblok/astro integration. Render the content without JavaScript first, and add JavaScript only where the reader acts.
7. If the component calls another origin from the browser, add that one origin to connect-src in the Content-Security-Policy and change nothing else in it. Show the policy before and after.
8. Run the type check (`astro check`) and the build, then list what changed, what you verified and what is still open.Requirements: the Storyblok MCP server connected, and an Astro site using @storyblok/astro.
Honest limits
The reader's code is sent to JetBrains' server after the click, and a note next to the button says so. It runs as JVM Kotlin only, not Kotlin Multiplatform: the request uses confType: java. The compiler version is pinned in the URL (2.4.21, listed as latestStable at api.kotlinlang.org/versions), so it will age. The server is a public service with no guarantee: rate limits, outages and timeouts are possible, so the blok shows "Could not reach the compiler server" after 20 seconds. Programs that read stdin get no input. Output that contains the text </outStream> confuses the tag parser, and the error line shows only the first File.kt frame.
Before you add your own blok, copy the schema of your content type to a file and diff the read-back against it.