Inline Agents
approvedby Dan
Turn "run this workflow" instructions in a note into a button that fires Claude Code or OpenCode at the note, with a live terminal underneath. - This plugin has not been manually reviewed by Obsidian staff.
Inline Agents
π Full documentation
An Obsidian plugin that turns "run this workflow" instructions in a note into a button. Click it, and it fires Claude Code or OpenCode at the note, in a live terminal right underneath β or, if you'd rather not watch it work, quietly in the background with the result dropped straight into the note when it's done.
Built to replace plain-text "Run features/learning-artefacts/teacher-artefact-leveller.md" instructions scattered across a note system with something clickable.
Install (local, this vault)
Already wired up: .obsidian/plugins/inline-agents is a symlink into this repo, so Obsidian loads it directly β no build step needed to use it as-is. Enable it in Settings β Community plugins (turn on Community plugins first if you haven't) and toggle Inline Agents on.
How it works
Each agent-button code block is its own self-contained widget, rendered via Obsidian's registerMarkdownCodeBlockProcessor. Clicking the button:
- Resolves which agent binary to run. The configured path in Settings is a preference, not a guarantee β
resolveBinary()(src/runner.ts) falls through a list of known install locations for the current OS/environment, then finally a bare command name so Node's ownPATHlookup can find it. This lets the samedata.jsonwork across different machines (e.g. a real Mac and a container) without hand-editing settings every time the vault is opened somewhere else. - Spawns the agent CLI behind a real PTY. Node's own
child_process.spawngives a child pipe-backed stdio, not a real terminal, and most CLI agents (Claude Code and OpenCode included) behave differently β or refuse to run interactively at all β without one. Rather than depending on a nativenode-ptybuild (which needs a fresh prebuild every time Obsidian's bundled Electron version changes), the plugin shells out topython3 -c "import pty,sys; pty.spawn(sys.argv[1:])", which does its own PTY allocation. Seesrc/runner.tsfor the full story of why (a first attempt using BSDscriptbroke specifically under Obsidian's process spawning). - Feeds the PTY's output into an xterm.js terminal instance rendered in an accordion under the button, so what you see is a real terminal, not a plain text log β ANSI colors, redraws, and all. You can type directly into it exactly like a normal terminal (approve/deny tool calls, answer questions) whenever the agent isn't auto-approving.
- Once the process exits (or, in
responseEndcompletion mode, once the agent finishes its reply), the button returns to its idle state β and ifagentOutput: fileis set, the captured terminal output is cleaned up and written into the note.
Everything downstream of "read the rendered xterm buffer" β turn-state classification for completion: responseEnd, and clean-text extraction for agentOutput: file β works off xterm.js's own rendered screen (src/outputCapture.ts) rather than parsing the raw ANSI stream by hand. xterm has already done the hard work of turning cursor moves and redraws into a stable 2D grid of text, so reading that is far more reliable than trying to pattern-match a live ANSI stream.
Usage
Drop a fenced code block with the agent-button language tag anywhere in a note:
```agent-button
text: Run Sufficiency Check
prompt: Run features/learning-artefacts/teacher-artefact-sufficiency-check.md on {{file.path}}
autoApprove: true
agent: ClaudeCode
model: myMainModel
agentOutput: file
append: belowButton
showTerminal: false
completion: responseEnd
```
Every field except text and prompt is optional and falls back to a sensible default.
Fields
textβ the button label.promptβ what gets sent to the agent as its first message. Supports{{ }}context and{{= }}expressions β see Context and templating below.autoApprove(true/false, optional) β overrides the plugin-wide Auto-approve by default setting for this one button. When on, the agent runs with permission checks bypassed (claude --dangerously-skip-permissions/opencode run --auto). When off, it asks before each tool use, right there in the terminal.agent(ClaudeCode/OpenCode, case-insensitive, optional) β overrides the plugin-wide Default Agent setting for this one button. Unrecognized or omitted values fall back to that setting.model(optional) β which model the agent uses for this run. Either thenameof a model mapping configured in Settings for whichever agent this button resolves to (see Model selection below), or a literal model string passed straight through to the CLI's own--modelflag. Omitted entirely, no--modelflag is passed at all β the CLI falls back to its own default (env var, its own config, whatever it'd use if run bare from a terminal).agentOutput(terminal/file, defaultterminal) β where the agent's output ends up.terminalβ output stays in the live terminal accordion, as before. Nothing is written to the note.fileβ once the run finishes, the captured output is written into the note itself (see How file output is captured below). The terminal accordion is still available while the run is live (subject toshowTerminal); it's only the destination of the final result that changes.
append(top/bottom/belowButton, defaultbottom, only meaningful whenagentOutput: file) β where the captured output block gets inserted.bottomβ appended to the end of the note.topβ inserted after the frontmatter block (if any), otherwise at the very top of the note.belowButtonβ inserted directly below theagent-buttoncode block that triggered the run. The block's position in the note is snapshotted at click time; if the note has changed shape by the time the run finishes (edited, block moved), the plugin sanity-checks that snapshot against the note's current content and falls back tobottomrather than risk inserting into the middle of an unrelated paragraph.
showTerminal(true/false, defaulttrue) β whether the terminal accordion opens automatically when the button is clicked.trueβ the terminal appears in place immediately, as it always has.falseβ the terminal stays collapsed; only the "Show terminal" toggle appears below the button, so you can open it on demand without it popping open every time.
completion(responseEnd/manual, defaultmanual) β how the button's loading state is resolved. See below.
Completion modes
This controls when the button's spinner clears β which is a separate thing from whether the underlying agent session is still alive.
manual(default) β the button stays in its loading state until the terminal session is closed, either by hand (closing the terminal, or the note itself, which sends the sessionSIGINT) or via the small checkmark button that appears to the right of the main button whenever a run is live under this mode (hover it to see the "Complete" tooltip). Clicking it kills the session and returns the button to idle. This is the safest default: nothing decides "the agent is done" on your behalf.responseEndβ the button's loading state clears automatically as soon as the agent finishes responding, without killing the underlying session β it keeps running in the background until the note is closed or it's killed some other way. This is heuristic: the plugin watches the rendered terminal for Claude Code's own turn-completion marker (a line likeCrunched for 7s Β· done 2:54 PM) and clears the spinner when it sees one. If the agent is instead showing a dialog or question it's blocked on β a permission prompt, a "yes/no" confirmation β the spinner is swapped for a βοΈ icon instead of clearing, so a run that's actually waiting on you doesn't silently look finished. This detection is tuned against Claude Code's observed CLI output and is unverified against OpenCode, which may not use the same conventions at all (see Known limitations).
Context and templating
prompt: isn't just a static string β it's a template rendered against a snapshot of the note the button lives in, taken at click time (src/context.ts / src/templating.ts). Two tiers:
{{ path.to.value }} β plain lookups, always available. Dot-path access into the context object below. An unknown root name (e.g. a typo like {{flie.basename}}) is left in the prompt untouched, so the mistake stays visible rather than silently vanishing; a missing value under a known root (e.g. no frontmatter, or a field that isn't set) renders as an empty string.
Available context:
| Path | Value |
|---|---|
file.path | Vault-relative path of the note the button is in |
file.basename | Filename without extension |
file.name | Filename with extension |
file.folder | Vault-relative path of the containing folder ("" at vault root) |
file.extension | File extension without the dot |
file.frontmatter.<key> | Any YAML frontmatter field |
file.tags | All tags on the note (frontmatter tags + inline #tags, deduped) |
file.ctime / file.mtime | Creation / modification time, ISO 8601 |
vault.name | Vault's display name |
vault.basePath | Vault's real filesystem path |
date.today | Today's date, YYYY-MM-DD |
date.now | Current timestamp, ISO 8601 |
{{= expression }} β a small expression language, opt-in. For anything a plain lookup can't express β conditionals, comparisons, string building β {{= }} evaluates a compact expression against the same context, with property access (file.frontmatter.status), string/number/boolean literals, + - * / %, comparisons (== != === !== < <= > >=), && || !, a ternary (cond ? a : b), and a small fixed set of helper functions: join(arr, sep), upper(s), lower(s), default(val, fallback), includes(collection, item), length(x). For example:
prompt: {{= file.frontmatter.status === "draft" ? "Finish drafting " + file.basename : "Review " + file.basename }}
This is deliberately not arbitrary JavaScript via eval/new Function β Obsidian's own community-plugin lint (eslint-plugin-obsidianmd) flags that pattern and its config marks the rule non-suppressible, a hard constraint for anything submitted to the community plugin directory, not a style preference. src/expr.ts is a small hand-written parser/interpreter instead: no assignment, no loops, no access to anything outside the context object, and a fixed, closed set of callable names β there's no code-execution surface to sandbox because there's no way to reach outside the grammar. It covers the realistic "compute part of a prompt from this note's metadata" use case without that risk.
Because it can run arbitrary-ish logic (however constrained) on every click, {{= }} is off by default β a button using it does nothing until Allow JavaScript expressions in prompts is turned on in Settings β Inline Agents. While off, a button whose prompt contains {{= }} writes a clear error to the terminal and doesn't run, rather than silently sending the literal {{= ... }} text to the agent.
Model selection
Neither CLI is told which model to use unless a button's model: line resolves to one β with no model: line at all, a button behaves exactly as it always has (no --model flag passed, CLI picks its own default).
Settings β Inline Agents has a Model mappings list under each provider (Claude Code / OpenCode) β pairs of a short name (what you type after model: in a button) and the actual model string sent to --model. Click + to add a row, β to remove one. Each row's model field is a free-text input with a βΎ dropdown next to it listing that provider's known models:
- Claude Code β no CLI command exists to list its models live, so this is a small curated list of the aliases/IDs
claude --model --helpdocuments (sonnet,opus,fable,haiku, and their fullclaude-*IDs). Type or paste any other model string directly if it's not in the list β the field never restricts you to what's in the dropdown. - OpenCode β a live list from
opencode models, which covers every provider OpenCode is currently configured for, including a local one (e.g. an Ollama config pointed athttp://host.docker.internal:11434/v1from inside a container, orhttp://localhost:11434/v1on the same machine Ollama runs on β see opencode.ai/docs/providers). Refreshed automatically when Settings opens, or on demand via the β» button next to the provider's name.
Every option in the dropdown has its own copy button (β§) β use it to grab the raw model string for pasting straight into a button's model: line without setting up a mapping at all.
Each provider's heading also shows a connection status dot (π’/π΄), refreshed the same way as the model list β green means the configured binary actually runs (<bin> --version exits 0), red means it doesn't (not installed, wrong path, etc.). It's a binary-resolves check, not an auth/provider-reachability check β a red OpenCode dot with Ollama unreachable still shows as OpenCode itself being fine; run a button to see the actual provider error.
How file output is captured
When agentOutput: file is set, the plugin renders the full xterm buffer to plain text once the run ends and strips out what it recognizes as its own scaffolding or CLI/TUI chrome β the $ <binary> <args> echo line, Claude Code's own prompt echo, the turn-completion summary line, the persistent bottom status bar, box-drawing-only lines, and (if the session was killed mid-run) the raw Python KeyboardInterrupt traceback that pty.spawn surfaces on SIGINT. What's left is written into the note as:
**Agent output** (<button text>, <timestamp>)
<captured content>
This captures the whole visible session transcript (tool-call summaries included, not just the agent's final reply) rather than attempting to isolate "the assistant's message" specifically β doing real semantic extraction of just the reply would need much deeper, CLI-specific knowledge of each agent's exact output format than is safe to assume here. It's good enough to drop into a note as a record of the run; it isn't a guarantee of a perfectly clean, chrome-free transcript in every case, especially for CLIs whose output this hasn't been tuned against.
Settings (gear icon β Inline Agents): the Default Agent used when a button does not specify agent:, each agent's binary path and connection status, each agent's model mappings (see Model selection above), the default auto-approve toggle, and whether {{= }} expressions in prompts are allowed to run (see Context and templating above).
Known limitations (MVP)
- PTY allocation uses
python3, notnode-pty. First cut used BSDscript -q /dev/null, which is broken specifically when Obsidian spawns it (script: tcgetattr/ioctl: Operation not supported on socketβ Node's default piped stdio hands the child a socket-backed fd, and script's setup path can'ttcgetattrthat). Fixed by shelling out topython3 -c "import pty,sys; pty.spawn(sys.argv[1:])"instead, which does its own PTY allocation and doesn't have that dependency. Needs apython3at one of a few standard locations, or onPATHβ true on basically any Mac or Linux environment. - Binary path resolution is best-effort.
resolveBinary()tries the configured setting, then a hardcoded candidate list of known install locations, then falls back to a bare command name for Node's ownPATHlookup. It's been verified across a couple of real environments but isn't exhaustive β an unusual install location may still need to be set explicitly in Settings. - OpenCode's basic command shape is now verified live (
opencode run [--auto] [--model <provider/model>] --dir <path> "<prompt>", against a realopencodeCLI talking to a local Ollama provider β confirmed both listing models viaopencode modelsand getting a real completion back through a button). TheresponseEndcompletion heuristic and file-output chrome-stripping, however, are still tuned specifically against Claude Code's observed terminal output and remain unverified for OpenCode, which may use different completion markers, dialog phrasing, or none of the above β treat OpenCode runs withcompletion: responseEndoragentOutput: fileas a test until that's checked too. agentOutput: fileandcompletion: responseEndare heuristic, not exact. Both work by pattern-matching the rendered terminal screen against text Claude Code has actually been observed to print, not by any structured signal from the agent itself. A CLI update that changes its output formatting, or output that doesn't match the patterns this was tuned against, can cause a run to be misclassified (e.g. the spinner not clearing, or a stray line surviving into the captured file output).- Closing the note kills a
manual-mode run. Each button's process is tied to its block's lifetime; navigating away from the note (destroying that block) sends the child processSIGINT. There's no background-run registry that survives the view closing entirely β a run started undercompletion: responseEnddoes outlive the button going idle, but not the note being closed. - No live terminal resize signal to the child. The PTY is allocated via
python3'spty.spawn()rather than a nativenode-ptybuild, specifically to avoid Electron-ABI version-pinning headaches. The trade-off isSIGWINCH/ioctlresize forwarding β a long run in a pane you resize mid-flight may wrap oddly, but content still comes through correctly. - One button = one fixed workflow. Each
agent-buttonblock has one hardcoded prompt; there's no dropdown/picker for choosing a workflow at click time. A real improvement here would be auto-detecting existing plain-text "RunX.md" instructions in a vault and turning them into buttons automatically β not yet built. {{= }}expressions are regex-delimited, not brace-matched. Both{{= }}and{{ }}are found with a non-greedy match up to the first}}, so an expression containing a literal}}(most plausibly a nested object/array literal) won't parse correctly. Realistic prompt-building β property access, comparisons, string concatenation, ternaries, the built-in helpers β doesn't hit this; it's a limitation of the templating layer aroundsrc/expr.ts, not the expression language itself.&&,||, and? :in{{= }}always evaluate both sides, unlike real JS short-circuiting. Harmless in practice β everything in the expression language is a pure, total function of its inputs (property access never throws, and the only callable functions are the fixed pure helpers), so there's no side effect an unused branch could produce β but worth knowing if you were expectinga || expensiveLookup()to skipexpensiveLookup().- Uses Node's
child_processandfsdirectly. This is whatisDesktopOnly: truein manifest.json is declaring β the whole plugin exists to spawn a real CLI process (Claude Code / OpenCode) behind a PTY and locate the vault on disk to use as its working directory, so this isn't optional. Obsidian's own automated review flags direct filesystem/shell access as a Warning under BEHAVIOR β an accurate observation about this plugin's Node/Electron dependency, not a bug, and not something a desktop-only plugin like this one can avoid. src/runner.ts's Node API surface is asserted through local interfaces, not left as raw@types/nodetypes. Obsidian's community-review type-checker doesn't resolve Node's ambient declarations the way this repo's owntsc/eslint do βprocess, and the return values ofexistsSync/spawn, all come back typedanyon their end, cascading into a wall of@typescript-eslint/no-unsafe-*findings even thoughnpm run linthere was always clean. Moving@types/nodeintodependencies(tried first, on the theory their checker only installsdependencies) turned out not to be it β a preview scan of that exact commit still showed every warning. The fix that actually worked: every touchpoint inrunner.tsnow goes throughX as unknown as Tinto a small hand-written interface describing just the shape this file uses. That assertion type-checks β and produces the same concrete, non-anytype β no matter whether the underlying value resolves asany, a real Node signature, or anything else, so it closes the gap in both environments at once rather than guessing at which one is right.- The settings tab uses the older imperative
display()API, not the declarativegetSettingDefinitions()one. The model-mapping list (dynamically added/removed rows, a custom dropdown with a per-option copy button, live connection-status dots) needs DOM control the declarative control types (dropdown/toggle/text) can't express. Trade-off: this tab no longer appears in Obsidian's own in-app settings search, which the declarative API opts into β see the docstring onAgentConsoleSettingTabinsrc/settings.ts. - Claude Code's model dropdown is a static, curated list, not a live one. There's no
claudeCLI subcommand to enumerate available models the wayopencode modelsdoes for OpenCode, so Settings ships a fixed list of the aliases/IDsclaude --model --helpdocuments (sonnet,opus,fable,haiku, plus their fullclaude-*IDs) β seeCLAUDE_MODEL_OPTIONSinsrc/settings.ts. It'll drift as Anthropic ships new models; the field is always free-text, so any current model string can still be typed or pasted in regardless. - xterm.js's own CSS is vendored wholesale into
styles.css. A couple of its rules (underline-styletext-decorationcombinations, and an!importanton.xterm-dimneeded to override inline styles xterm.js sets at runtime β see the comment above it) trip Obsidian's CSS lint. Left as shipped by xterm.js rather than hand-edited, since changing vendored terminal-rendering CSS to satisfy a style-guide check risks breaking ANSI rendering for the sake of a cosmetic warning.
Dev
npm install
npm run dev # esbuild --watch, writes main.js on save
npm run build # typecheck + production (minified) build
Source lives in src/; main.js is generated (gitignored) β see esbuild.config.mjs.
For plugin developers
Search results and similarity scores are powered by semantic analysis of your plugin's README. If your plugin isn't appearing for searches you'd expect, try updating your README to clearly describe your plugin's purpose, features, and use cases.