Cauchy

Agentic incident response notebooks

This post is based on work done at the DNB Cyber Defense Center on notebook-driven security operations. An earlier version of parts of this work was published on the marimo blog as a case study. This post covers what we built: a marimo-based interface for incident response, threat hunting, and threat intelligence workflows, from remote data source integration through agent-built case notebooks.

We built an incident response workflow around marimo notebooks: one executable case environment for querying security platforms, joining evidence locally, deploying focused web applications, and giving agents the same context analysts use.

Incident response is integration work. Alerts and raw events may be in Splunk, Sentinel, or Defender XDR. Vulnerability data may live in Databricks, Snowflake, or another analytical backend. Other context is more on-demand: cloud control plane state, directory state, asset ownership, change tickets, threat intel feeds. The analyst has to pull those pieces into one explanation and document the decision.

Most security tools are good at one part of that job. SIEMs are good at search. Case systems are good at status and playbooks. Dashboards are good at showing well-defined views. But the middle of an investigation is exploratory: many tabs open, a notes document on the side, copied tables, rewritten queries, manual joins, and a growing explanation of what probably happened. We wanted an interface that preserves that flexibility while making the work executable, reproducible, and directly controllable by agents.

We also wanted the architecture to stay composable. Splunk, Defender XDR, and Snowflake are integrations, not hard dependencies. The same notebook surface should work if a query engine, data warehouse, or security platform is replaced with an open source equivalent, as long as the replacement can return typed tables and pass context into the workflow.

A sped-up walkthrough of the agent building a case notebook end to end through marimo-pair. The case shown is a simplified scenario triggered on a laptop rather than a live alert, since real cases are confidential, and nothing on screen is sensitive. Each cell is authored and executed by the agent, and the finished notebook is the case file. The agent interface itself is covered later in the post.

Why marimo fits

We chose marimo because it treats a notebook as a reproducible program. In a traditional notebook, cells can be run out of order and outputs can depend on hidden state from earlier executions. Here, dependent cells recompute from the code that is actually visible. Change the host, time window, or selected row, and every dependent query and chart recomputes from the same state. That matters in incident response, where the conclusion should still be connected to the query and evidence that produced it.

The notebook also has a practical operational lifecycle. The file is plain Python, so it can be reviewed, versioned, moved, served as an app, exported as static HTML, or driven by an agent through marimo-pair. Those properties are what let one case notebook become the analyst workspace, the shared view, the archived report, and the agent handoff point.

It is also a practical base interface. The runtime already connects to analytical backends such as Snowflake, Databricks, ClickHouse, Postgres, BigQuery, and DuckDB. For security systems that do not expose a SQL interface, such as Splunk, Microsoft Sentinel, or advanced hunting in Microsoft Defender XDR, we built an SDK for this use case: incident response notebooks that can run native SPL and KQL, cast the remote results into a typed dataframe, and continue the investigation in the notebook. In our notebooks, we usually use the ibis backend so Splunk results, Defender XDR events, and Snowflake tables can be joined locally through DuckDB.

The agent interface is the same notebook. With marimo-pair, an agent can create a new notebook or open a running one, execute temporary Python that can read notebook state, and make durable changes by writing and running Python and SQL cells in the live notebook. Those cells can call our SPL and KQL decorators, inspect outputs, and revise the notebook. The model is not filling a form from the outside. It is working inside the same analysis surface as the analyst, with code, outputs, and intermediate state in the loop.

Notebooks are not new to security operations. What changes here is the combination: the analyst context, the data sources, the executable analysis, and the agent all meet in one running notebook. Instead of maintaining one notebook template per alert type, the agent can build the notebook that this task needs. The same pattern carries naturally beyond investigations into threat hunting and threat intelligence.

There are three parts to the system. First, security data access: Splunk, Defender XDR, and other security tools keep their native query languages, but notebook code receives typed tables that can be joined locally. Second, the operational notebook: the same .py file works as an analyst workspace, a shared live view, a deployed app, or an archived report. Third, the agent loop: an agent can build and revise the case notebook from alert context, using the same data access patterns an analyst would use.

The common artifact is the case notebook. Everything below is about making that artifact useful: connected to the right data, executable and reproducible, easy to share or deploy, and editable by both analyst and agent.

The first problem is security data access. If the data is already available through a source marimo can access natively, you are ready to work. If not, we need a way to run the source’s native query language and bring the result back into the notebook.

Connecting data sources

Security logs are often stored in SIEMs and security platforms such as Splunk, Sentinel, Elastic, and Defender XDR. To use those logs in a notebook, the first step is not a database connection. It is running the query the source already understands: SPL against Splunk, or KQL against Sentinel and advanced hunting in Microsoft Defender XDR.

The problem is what comes back. Splunk indexes events with default metadata fields, then derives many other fields through field discovery and search-time field extractions tied to a source, sourcetype, or host. The REST API can return search results as JSON, but that is still a loosely structured result set rather than a dataframe schema: fields can depend on the events and extraction rules that matched, and timestamps, numbers, multivalue fields, and nulls still need explicit handling. Long searches run as async jobs that need to be submitted, polled, and paginated. KQL sources have their own APIs and response formats. Before the result can be combined with Snowflake query results, filtered by widgets, or transformed in downstream cells, the notebook needs it in a dataframe or table shape with known column types.

The missing gap between a security platform and a notebook. A Splunk query can be valid SPL, and Splunk can return valid JSON, but downstream cells still need predictable columns and types. The SDK wraps the remote query, normalizes the response, and returns the configured tabular format so the rest of the notebook can join, filter, and display the result.

The SDK gives this pattern a small Python interface. You write a function that returns the native query string, optionally declare the expected columns and types, and the decorator handles authentication, execution, type conversion, and caching. A global backend option controls the tabular return type: Polars, pandas, PyArrow, or an ibis table backed by an in-memory DuckDB connection. The notebook-facing result can then be joined, filtered, and reshaped with the rest of the investigation.

The interactive cells below illustrate the SDK’s API using mock Splunk and KQL services - the queries run against synthetic data in your browser rather than live security platforms. The real notebooks run server-side against production data, and these examples are simplified to show the shape of the code.

Here is a Splunk query. The function returns SPL, and the decorator declaration specifies the expected output columns and their types. The rt() helper resolves relative time expressions such as "-1h" and "now" into the bounded time window that the decorator requires:

Loading…

The @spl.df decorator. The function returns SPL, the decorator runs the search and casts every column according to the declared column types. The decorator reads earliest_time and latest_time from call-time kwargs and enforces that both are set, so every remote search has a bounded time window. The same function can then run over a short triage window or a longer retroactive hunt. In this notebook the backend is ibis, so the result is available as a table in the shared DuckDB connection.

Production notebooks usually declare column types with short strings such as "ts", "str", "int", and "float". The declaration does two things at once: it casts Splunk’s untyped JSON into proper column types, and it surfaces drift loudly. A Splunk index can contain multiple event categories, so a query that used to return one field set can suddenly include another category’s fields too. With an explicit declaration, that mismatch shows up as a cast error on the next run instead of as silently null columns downstream.

Those strings are deliberately backend-agnostic. Under the hood, the SDK maps each one onto the right dtype for the active tabular backend, so "ts" becomes the timestamp type that Polars, pandas, PyArrow, or ibis expects. That keeps the notebook code stable while the return type changes underneath it.

When exploring an unfamiliar index where the field set is unknown, the SDK can infer it once: it runs | fieldsummary over a narrow time window and passes the result to a pydantic-ai agent backed by a small LLM (for example, Haiku) with two tools - try_cast to verify a guessed dtype against sample values, and check_datetime to confirm a string parses as a timestamp. Inferred declarations are cached persistently so the LLM round trip happens once per index.

Inferring column types once. A narrow-window | fieldsummary hands the agent each field with sample values, the agent proposes a type and checks it with try_cast and check_datetime, and the confirmed declaration is cached so the model runs once per index rather than on every query. Hover a node for details.

Same pattern for KQL. Advanced hunting in Microsoft Defender XDR (and Microsoft Sentinel) returns typed responses via the Microsoft Graph security API, so no schema declaration is needed:

Loading…

@kql.df against advanced hunting in Microsoft Defender XDR. The SDK infers types from the Graph API response. With the ibis backend selected, the result lands in the same DuckDB connection as the Splunk table above.

For SQL-like analytical sources, there is no decorator. The client runs the source’s native query path, receives a result set, converts it through Arrow, and hands the notebook the configured tabular type:

Loading…

sf.table() represents the SQL-source path. The remote system does the database work, the result set comes back through Arrow, and the notebook continues with the same dataframe or table interface used for the security-platform results above.

Async jobs, parallel fan-out, and caching

A oneshot Splunk search (@spl.df) uses the HTTP search endpoint and can time out on larger queries. Heavier searches, such as fleet-wide aggregations over endpoint telemetry, may need to run as server-side Splunk jobs. The SDK exposes both shapes as @spl.df and @spl.job. The decision is simple: @spl.df for the fast majority, @spl.job for anything that would time out or return more than a few thousand rows. @spl.job also surfaces a nested marimo progress bar so the analyst can see the search advance rather than stare at a spinning cell.

Two Splunk execution paths. Fast searches use the synchronous endpoint and return rows directly. Longer searches create a server-side job, poll until the job is done, page through the result set, and update a marimo progress bar while the notebook waits.

Both decorators are async-native, so multiple jobs run concurrently with asyncio.gather from the standard library. The RBA worked example below fans out across identity context, PIM history, device logons, the Azure AD audit timeline, and CMDB context indexed in Splunk. Serially that can take minutes. With gather, the notebook waits as long as the slowest single fetch.

Caching remote queries. marimo’s reactive execution model is exactly what we want for investigation notebooks: change a widget, and every dependent cell updates. The catch is that a dependent cell may be a Splunk search, and moving a dropdown or slider should not submit the same remote query ten times. One-shot searches may time out quickly, async jobs may live only for a short period, and a case notebook gets re-run constantly while the analyst is working.

To guard against that, the SDK leans on marimo’s caching mechanics around the remote fetch boundary. marimo’s cache supports async functions, preserves cached values across cell re-runs unless the relevant source code changes, and keys on function arguments plus closed-over variables. In our case, that means the fetch cache can account for the Python cell definition, the resolved time window, the native query string, and the relevant execution options. A stable investigation window can be re-used across reactive re-runs, while changing the query function or asking for a genuinely different window goes back to the source.

The cache boundary in a reactive notebook. A widget change can re-run downstream cells many times, but the remote fetch cell only goes back to Splunk when the function definition, query, options, or resolved time window changes. Cache hits let the rest of the notebook update without submitting another remote search.

The tricky part is relative time. Splunk’s native time syntax uses expressions like "-24h" or "-7d@d". If you cache based on those strings, the key "-24h" hashes to the same value regardless of when the query actually ran. The analyst reopens their notebook the next morning, the cache hits, and they get yesterday’s data without any indication that it is stale.

The SDK addresses this with rt(), a function that resolves relative time expressions to absolute UTC timestamps at the moment of invocation. Same query, different hour, different cache entry:

# BAD: the string "-1h" never changes, so the cache key never changes
await connections(earliest_time="-1h", latest_time="now")

# GOOD: rt() resolves to an absolute timestamp at call time
await connections(earliest_time=rt("-1h"), latest_time=rt("now"))

The cell below illustrates the behavior using the mock SDK. The mocked remote boundary sleeps on a cache miss, then returns immediately on a hit. Both calls use identical resolved timestamps, so the second call finds the memory cache entry instead of submitting another search:

Loading…

Cold call versus cache hit. The demo uses a short artificial sleep to make the remote boundary visible. In production the cold call can take seconds against a loaded Splunk index, while the hit returns from local cache. The point is the boundary: remote data is fetched once for a resolved window, then downstream reactive cells can re-run without re-querying Splunk.

The SDK also raises an explicit error if latest_time is in the future: caching a window that has not yet closed would store incomplete results. And if you accidentally pass an unresolved relative string like "-24h" when caching is on, you get a warning in the console - the cache key would be the literal string, meaning it would never refresh. The rt() call is the guard.

Working the data locally. At this point the remote systems have already done their work. Splunk has returned proxy events, Defender XDR has returned process activity, and Snowflake has returned vulnerability findings. The SDK has landed each result as an ibis table backed by the same local DuckDB connection, so the investigation now continues entirely on local data. There is no further Splunk search or cross-product export to run. From here it is ordinary notebook work: join the tables on a shared key, filter and aggregate, and wire the result to interactive widgets that recompute reactively as the analyst drills in. The first move is the join, correlating the three sources on hostname:

Loading…

Three sources joined on hostname. Vulnerability findings from Snowflake correlated with proxy traffic from Splunk and process activity from Defender XDR. The join runs locally in DuckDB after the remote fetch boundary, which means downstream cells can filter, aggregate, and display the result without going back to the source systems.

Because marimo is reactive, adding a filter widget requires no callback wiring. The dropdown is a Python variable in the reactive DAG. Select a host and every downstream cell that depends on the joined table re-evaluates automatically:

Loading…

The filtered join for a single host. The shape of a playbook: pull from multiple sources, join on a shared key, let the analyst drill down without writing additional query logic.

Worked examples

The three example notebooks below demonstrate the SDK, each built around a different analysis shape: a hash join against a threat intel feed, a temporal correlation across separate telemetry searches, and a parallel fan-out for identity and case context. The data, hostnames, identities, and verdicts in all three are synthetic.

Bring Your Own Vulnerable Driver attacks use legitimate but vulnerable kernel drivers to disable security tooling. LOLDrivers maintains a community-curated database of these drivers as SHA256 hashes. The hunting pattern: pull driver load telemetry, match against LOLDrivers, investigate anything that hits.

The smallest viable hunt loop: an external threat-intel feed (LOLDrivers) joined against fleet driver-load telemetry. What matters in the diagram is that neither source needs special treatment: the CSV reads into Polars, the telemetry query returns a Polars dataframe, and the join happens inside the notebook. No ETL into a security data lake first.

Load threat intelligence. Pull the LOLDrivers hash list into a local polars DataFrame. The reference set we match our telemetry against:

Loading…

The full LOLDrivers CSV contains over two thousand entries across vulnerable, malicious, and known-abused categories. Here we show a subset for clarity.

Query telemetry. Next, query Splunk for driver load events from Defender for Endpoint telemetry. Rather than pulling every raw event (millions of rows across a fleet), we run an aggregation that returns unique SHA256 hashes with load counts and device spread:

Loading…

The stats command deduplicates at the hash level. Thousands of drivers load daily across the fleet, but we only compare unique hashes against LOLDrivers. Individual events come later, once we have a confirmed hit.

Join and identify hits. Join on SHA256. Anything that survives is a driver loaded in our environment that matches a known-vulnerable sample in LOLDrivers:

The join logic as a set intersection. LOLDrivers ships around two thousand known-vulnerable and known-abused driver entries. The fleet loads a few thousand distinct drivers a week. The intersection (highlighted) is what we care about: drivers that are both loaded in our environment and catalogued as vulnerable.

Loading…

One hit: truesight.sys, a known EDR killer catalogued by LOLDrivers. The same join pattern works regardless of where your telemetry lives - swap the data-retrieval decorator and the rest stays the same.

Drill down. Confirmed hit. Drill down on that hash to pull the raw load events - device name, folder path, initiating process - the context needed to decide whether this is malicious or a false positive:

Loading…

The driver loaded from C:\Windows\Temp on a single device, a non-standard path for a kernel driver. Combined with the LOLDrivers match, a vulnerable signed driver staged in a temp directory and loaded as a kernel service is the BYOVD pattern, not a legitimate driver update. The specific event values here are LLM-generated for illustration, since real telemetry is confidential. This mirrors the shape of the production hunt in our environment: the LOLDrivers community feed joined against endpoint driver-load telemetry, the join running locally in DuckDB, with a click-to-investigate drilldown for each hit.

The notebooks these demos illustrate are the same shape as the production hunts and triage workflows we run. Once the notebook is useful, the next question is how it becomes a shared operational surface rather than a file on one analyst’s machine.

Deployment

Up to here the notebook has behaved like a local, single-user workspace: one analyst, one running notebook, one investigation. Turning that into a team tool raises a different question: how do you run the same notebook remotely so several people can reach it, and so notebooks and session state persist between runs and survive a restart?

We kept the answer deliberately simple. Everything runs on a single ECS cluster, and four services mount the same S3 bucket through S3 Files access points, so a file one service writes is immediately visible to the others. That shared storage is what lets one .py file move between the analyst workspace, a remote app view, and an agent-driven session without a deploy pipeline in between. The marimo and dashboards services run notebook edit mode and app mode, covered in the next section. The other two, claude-tmux and claude-agent, are the agent interface, covered after that.

The shared storage topology. ECS services mount S3 Files access points and an EFS volume. A notebook written by the agent task appears immediately in the marimo UI. Session state persists on EFS across container restarts. The shared mount is what lets the agent and the editor share state without an explicit sync step. Hover a node for details.

S3 lifecycle policies make retention explicit: cached investigation data can be kept for a fixed maximum window, then expired automatically. The editor service is the shared runtime for notebook editing and agent pairing, with access controlled at the internal service boundary. Individual notebooks are accessed by URL path. The agent containers also carry Bedrock IAM permissions for model inference.

View mode. marimo’s view mode lets a second user open the same notebook as a live read-only viewer without disconnecting the first. The viewer sees the editor’s cells and outputs update in real time, and either side can take over editing with one click. In practice this is how a second analyst or a team lead follows an investigation as it is being built. The important distinction is local versus remote use: the notebook remains one .py file, but the remote runtime lets several people and the agent share the same live state.

Data apps

The same file also needs to work outside edit mode: as a live shared view, a focused app, or an exported report. marimo’s app mode (marimo run) hides the code and lays out the cell outputs as a web application. We don’t run marimo run directly. The dashboards service is a thin FastAPI app that mounts marimo’s ASGI app at /, with with_dynamic_directory pointing at the dashboards S3 mount:

import marimo
from fastapi import FastAPI

server = marimo.create_asgi_app().with_dynamic_directory(
    path="/dashboard", directory="/app/dashboards"
)

app = FastAPI()

@app.get("/health")
async def health_check():
    return {"status": "healthy"}

app.mount("/", server.build())

The dashboards service is deliberately thin. FastAPI owns health checks and process lifecycle, while marimo owns routing each notebook file to an app. The dynamic directory points at the shared dashboards mount, so the served set can change without restarting the container.

with_dynamic_directory serves every .py file under the directory as its own app, discovered at request time rather than at boot. Drop a new file into the mount and it is live at the next URL hit. No rebuild, no restart, no registration. The container’s job is to be the runtime.

The trick is that the editor and the app read the same bucket. The marimo service mounts both access points (/app/notebooks for working drafts, /app/dashboards for what gets served), while the dashboards service mounts only /app/dashboards. Because the editor can write directly into /app/dashboards, an analyst can edit a served notebook in place from the marimo instance and with_dynamic_directory picks up the change on the next request. There is no separate move, build, or deploy step. A notebook becomes an app the moment it lands in /app/dashboards, whether you edit it there directly or mv it across from /app/notebooks.

This shared mount is the convenient path, not the only one. The version-controlled data apps ship inside the container image, copied in when we rebuild it from GitHub, so the versioned set is always present on boot. The S3 mount sits on top of that as a fast self-service layer: an analyst can work on a notebook in the marimo editor and have it served as an app moments later, without waiting on an image build.

Two ECS services, one S3 bucket. The marimo editor mounts both access points, the dashboards FastAPI app mounts only the dashboards one. Because the editor can write to the dashboards mount, editing a notebook there serves it as an app directly, with no move or deploy step. with_dynamic_directory picks the file up on the next request.

The reactive execution model makes each served notebook more than a fixed dashboard. URL query parameters feed directly into the reactive DAG via mo.query_params(). The process tree notebook reads its scope from the URL:

query_params = mo.query_params()
device_name = query_params.get("device_name")
report_id   = query_params.get("report_id")
process_id  = query_params.get("process_id")
anchor_time = pendulum.parse(query_params.get("timestamp")).naive()

The app reads its scope from the URL. Those values become ordinary Python variables in the reactive graph, so changing the URL parameter changes the queries, joins, and visualizations that depend on it. The calling system only needs to construct the link.

For app mode, the integration can be as simple as a URL. Any system that can construct a link can drive the app: change the device name in the URL and every SQL query, every join, every visualization downstream re-executes with the new scope. The analyst sees a pre-focused view of their case without touching code, and the calling system does not need to know anything about notebook internals. SOAR is one useful caller and context source: a playbook can build the case URL, pass the case identifier, and open the notebook already scoped to that incident.

Two such apps are below: the process tree explorer and the RBA case triage we just saw in notebook mode. The process tree app takes a device and timestamp from the URL and renders an interactive tree of process creation events. The RBA app takes a case ID and presents the full risk-based alert case in one view.

The process tree app resolves the boot session from the timestamp, pulls all process creation events from Defender XDR, and renders an interactive tree with the anchor process highlighted. Selecting a node triggers reactive execution: downstream cells that show image loads, file writes, network connections, and registry modifications re-evaluate for the selected process. Click any node and choose “Load events” to see this in action.

Process Tree Explorer. The header shows incident context (device, user, boot session). The tree visualizes the full process hierarchy with the anchor process highlighted. Click any node for detailed telemetry in the tabbed panel below. This is a single marimo notebook running in app mode, using the same ProcessTreeWidget from a previous post.

Agent interface

The last layer is the agent loop: not a separate interface, but another way to work inside the same notebook. One idea recurs across notebook-driven security operations: a templatized investigation notebook for each alert type, with its visualizations, cells, and runbook steps laid out in advance. When the alert fires, the notebook auto-fills and runs, and the analyst takes over from there.

Apple described a version of this in a 2019 presentation on their threat detection and response notebooks, and Microsoft’s MSTICPy has long packaged the query, enrichment, and visualization building blocks for exactly this kind of investigation notebook.

In practice this is a lot to keep up. There are too many distinct signals and too much of the investigation is ad hoc, so a catalogue with one notebook per alert type drifts as detection rules evolve and becomes its own maintenance load.

The agent changes that. Instead of maintaining a template per alert, we give the agent skills and domain knowledge and let it build a case-specific notebook from scratch each time. The analyst inherits a working draft shaped to the alert rather than a stale generic template.

Two additional ECS services on the same cluster: a headless RunTask for automated builds, and a persistent tmux session for interactive use. Both mount S3 (so agent-built notebooks appear immediately in the editor) plus EFS at /home/nonroot/.claude for session state. Conversation history, memory, and project configuration persist across container restarts and are shared between services. This shared EFS is what enables the handoff - /resume picks up where the headless agent left off.

The notebooks plugin

marimo-pair is the transport layer: a Claude Code skill, in practice a handful of shell scripts, that gives the agent direct control of a running notebook (add cells, execute, read outputs, delete). The scripts speak to the marimo server’s ordinary HTTP API rather than any custom protocol. To run code, a script finds the server in marimo’s local registry, looks up the active notebook session, and POSTs the code to the kernel’s execute endpoint tagged with that session id.

marimo runs the code in the real kernel and streams back stdout, stderr, and a final result or error, which the script returns to the agent. Adding, reading, and deleting cells work the same way. The effect is that the agent operates the notebook through the same kernel the analyst’s editor uses and sees the actual outputs, exactly as a person typing into a cell would. The walkthrough at the top of the post is this loop running on a simplified scenario triggered on a laptop, since real cases are confidential.

That assumes a notebook session already exists, which holds when an analyst has the editor open but not on a headless ECS task that starts cold. Our notebooks plugin closes that gap. The admin skill handles the server lifecycle, file operations, exports, and session bootstrap that marimo-pair leaves out, so the agent can start with no analyst present. The rest of the plugin is domain guidance for the stack the agent writes against: SPL and ibis query patterns, marimo widgets and layout, investigation structure, and validation gates.

How the agent works inside one notebook. It reads the domain skills for what to build (investigation structure, SPL and ibis patterns, display idioms, RBA drilldowns), then acts: marimo-pair adds and runs cells on the HTTP kernel and reads their output, while the admin skill handles files and sessions over REST and WebSocket. The agent repeats this build, run, and inspect loop, revising as it reads outputs, until the validate gate confirms the notebook is complete. Hover a node for details.

The notebooks plugin structure. Skills define agent workflows as markdown instructions. The admin skill handles server lifecycle, especially ensuring marimo is running before attempting cell operations. References hold SDK patterns and investigation templates. Hover a node for details.

marimo-pair knows how to write cells to a live kernel. The notebooks plugin knows what cells to write and in what order. The video at the top of the post shows the two layered together on a simplified, self-triggered scenario rather than a live case.

The handoff is easier to see as one shared runtime. A case system supplies alert context, a headless agent creates the first notebook, and the analyst takes over without losing the notebook state or the agent session. There are two human-facing branches: open the notebook in the marimo editor through the browser, or reconnect to the same Claude Code session through ttyd and tmux with /resume. The editor talks to the live marimo kernel directly, while the interactive agent uses the same marimo-pair transport and notebooks plugin as the headless run.

The agent handoff. The top lane is the automated first draft: a case system passes context into a dispatcher, the dispatcher starts a headless agent, and the agent writes a case notebook. The bottom lane is human takeover: the analyst opens the notebook in marimo and can also resume the agent through ttyd and tmux. The browser path and the interactive-agent path both work against the same live marimo kernel. The implementation details matter less than the state boundary: notebook files and agent session state have to survive the handoff.

The notebook is the right artifact when the analyst is likely to continue working - add cells, drill down, test another hypothesis, write a verdict. Every cell’s output is reproducible, so the notebook is also evidence that the agent did what it claims. For finished reports, marimo exports to static HTML, and for some workflows a custom HTML page may be the better final surface. The important distinction is whether the investigation should remain executable after the first draft.

Summary

In this post we built an agentic incident response workflow around marimo. The goal was not to replace the security tools analysts already use, but to give incident response, threat hunting, and threat intelligence work a better execution surface: one case notebook where data access, analysis, evidence, explanation, and agent assistance can meet.

The first piece is data access in the form analysts actually use it. Splunk queries stay SPL, Defender XDR queries stay KQL, and analytical sources such as Snowflake keep their native interfaces. The SDK wraps those queries and returns typed tables, usually backed by DuckDB through ibis, so the notebook can join and filter results from multiple systems locally.

The second piece is the notebook as a stateful case artifact. The same .py file can run locally while an analyst explores, in a shared runtime while another person follows the live notebook, as an app when the case needs a focused interface, and as static HTML when the work should be archived. The important part is not the hosting arrangement. It is that code, outputs, widgets, cached remote results, and the analyst’s explanation stay attached to the same executable file.

The third piece is the agent handoff. marimo-pair gives the agent access to the live notebook kernel: it can add cells, execute them, read outputs, and revise. The notebooks plugin adds the domain knowledge around SPL, ibis, marimo layout, investigation structure, and validation gates. A headless run can produce the first draft, and the analyst can continue either in the browser editor or by resuming the same agent session. That continuity is the point: the notebook state and the agent state both survive the handoff.

The examples show the same pattern in several shapes: a BYOVD hash join against LOLDrivers, a temporal Sliver C2 correlation, risk-based alert triage, process-tree exploration, and an agent-built case notebook. The common thread is that the notebook stays close to the evidence. It is not only a report at the end of the investigation. It is the place where the investigation runs, where the intermediate joins and filters remain visible, and where the next question can still be asked.

There is an open design question here. Coding agents are very good at writing custom HTML, and for some case views that may be the fastest route: run the searches, render the result into a focused page, and give the analyst a polished interface. That can be enough when the artifact is mostly a view of results. The notebook approach makes a different tradeoff. Because the query code, transformations, outputs, and conclusion live in the same executable file, the case is reproducible in a way that a custom HTML view over already-materialized Splunk or Databricks results is not. The strategic question is whether the artifact is a finished view or the live investigation surface: executable, inspectable, resumable, and easy to extend.

There are still limitations. Agent-built notebooks are drafts, not verdicts, and need human review before conclusions are acted on. Isolation is still coarse, with cases sharing services at the directory level rather than running isolated per case, and the schema knowledge base has to be maintained as sources and parsers change. The natural end state is closer to a managed marimo workspace such as molab: configurable runtimes, per-case isolation, persistent storage, sharing, agent pairing, and clean spin-up, spin-down, and archive semantics around each case.