Extension System
Namespace:
com.blockether.vis.loop.runtime.conversation.environment.extension
Extensions are the only way to add symbols, classes, and documentation to the SCI sandbox. An extension is a namespace-like bundle that groups related tools, constants, prompt context, and per-iteration nudges into a single validated unit.
What an Extension Can Do
- Bind functions into an aliased namespace - the LLM calls
(alias/fn ...)from:code - Bind constants - data the LLM references via the alias prefix
- Inject prompt context - LLM-facing docs in the system prompt
- Emit per-iteration nudges - situational hints (budget, errors, etc.)
- Expose Java classes - enable
(LocalDate/now)style interop - Guard activation - conditionally enable/disable based on env state
Registration
Two ways to register extensions:
Global Registry (recommended)
Call register-global! at namespace load time. When any environment
is created, all global extensions are automatically installed in
dependency order.
(ns my.company.ext.git
(:require [....extension :as ext]))
(ext/register-global!
(ext/extension
{:ext/namespace 'com.acme.ext.git
:ext/requires ['com.blockether.vis.ext.editing]
:ext/doc "Git integration"
...}))
Drop the jar on the classpath → namespace loads → extension self-registers → every new environment gets it.
Auto-Discovery from Classpath (recommended)
Extensions can be discovered automatically without any manual
require. Place a META-INF/vis/extensions.edn file in your
extension’s resources/ directory:
[com.acme.ext.git
com.acme.ext.search]
When create-environment runs, it calls discover-extensions! which:
- Scans the classpath for all
META-INF/vis/extensions.ednfiles (viaClassLoader.getResources) - Reads each file as a vector of namespace symbols
requires each namespace (triggering itsregister-global!call)- Skips namespaces that are already registered
- Logs every success at
:infoand every failure at:error
This means: add the extension jar/local-root to your deps.edn aliases,
ensure it has a META-INF/vis/extensions.edn in its resources, and
it will be loaded automatically. No imports, no requires, no wiring.
Directory layout for an extension:
extensions/my-ext/
├── deps.edn ;; {:paths ["src" "resources"] ...}
├── resources/
│ └── META-INF/vis/extensions.edn ;; [com.acme.ext.my-tool]
└── src/com/acme/ext/my_tool.clj ;; calls register-global! at load time
deps.edn alias:
:run {:extra-deps {com.acme.ext/my-tool {:local/root "extensions/my-ext"}}}
Dynamic Loading
An extension can load other extensions at runtime:
(ext/load-extension! 'my.company.ext.git)
;; => requires the ns, triggers register-global!, returns the ext
This is how meta-extensions (extension packs) work - one extension
requires others dynamically.
Per-Environment (ad-hoc)
(register-extension! environment my-ext)
For extensions that shouldn’t be global.
Lifecycle
flowchart TD
Discover["0. discover-extensions!"]
Discover --> Build["1. ext/extension"]
Build --> Global["2. register-global!"]
Global --> Topo["3. Topo-sort by ext/requires"]
Topo --> Deps{"4. Dependencies met?"}
Deps -- yes --> Install["5. Install into environment"]
Deps -- no --> Fail(["Throws missing-dependencies"])
Install --> Prompt["6. Append ext/prompt to system prompt"]
Prompt --> Activate{"7. Per-query activation-fn?"}
Activate -- active --> Nudge["8. Per-iteration nudge-fn"]
Activate -- inactive --> Skip(["Symbols unbound, nudge skipped"])
Nudge --> Hooks["9. before-fn, fn, after-fn"]
Step details:
- discover-extensions! - scan
META-INF/vis/extensions.ednon classpath - ext/extension - build and validate extension spec
- register-global! - add to process-level registry
- Topo-sort - order by
:ext/requiresdependencies - Dependencies - all required extensions must be registered
- Install - bind symbols into aliased SCI namespace, auto-require alias in sandbox
- Prompt - append
[namespace: alias → ns]header +:ext/promptto system prompt - Activation - per-query
activation-fncheck - Nudge - per-iteration
nudge-fncalled - Hooks - per-call
before-fn,fn,after-fn,on-error-fn
Namespace Aliases (required)
Every extension must declare :ext/ns-alias - a map with :ns
(the full SCI namespace symbol) and :alias (the short alias the LLM
uses). Extension symbols are bound only into this dedicated
namespace, never into the sandbox namespace directly. The LLM
must always use the alias prefix.
(ext/extension
{:ext/namespace 'com.blockether.vis.ext.editing
:ext/ns-alias {:ns 'vis.ext.fs :alias 'fs}
...})
At register-extension! time:
- A SCI namespace
vis.ext.fsis created with all wrapped symbols - The alias
fsis registered in the SCI context (require '[vis.ext.fs :as fs])is auto-evaluated in the sandbox- The LLM calls
(fs/read-file ...),(fs/list-files ...), etc. - Bare
(read-file ...)does not resolve - the alias is mandatory
The system prompt auto-prepends a namespace header to each extension’s prompt block:
[namespace: fs → vis.ext.fs]
Filesystem tools (use fs/ prefix):
- (fs/read-file path) ...
Extension-declared :ext/classes and :ext/imports are also injected
into the SCI context, so (LocalDate/now) works if an extension
exposes java.time.LocalDate.
Prompt Injection
Every active extension’s :ext/prompt is appended to the system
prompt at the start of each query. This is how the LLM knows which
tools are available in the sandbox.
loop-core/assemble-system-prompt is the single function that
builds the complete system message. It:
- Builds the core system prompt (
CORE_SYSTEM_PROMPT+ date + environment block + optional caller instructions) - Collects extension prompts: for each extension where
(:ext/activation-fn ext) environmentis truthy,(:ext/prompt ext) environmentis called - Joins all active prompts with
\n\nand appends to the core prompt
Both iteration loop paths (loop/core.clj and query/core.clj) and
the TUI [?] inspector (conversation/core.clj :: effective-system-prompt)
call this same function — zero duplication, zero drift.
If an extension’s activation-fn or prompt fn throws, the error is
logged at :error level and that extension’s prompt is skipped —
the query still runs.
Quick Example
(ns com.acme.ext.search
(:require [c.b.vis.loop.runtime.conversation.environment.extension :as ext]))
(defn- search-fn [query] ...)
(def search-ext
(ext/extension
{:ext/namespace 'com.acme.ext.search
:ext/doc "Document search"
:ext/group "knowledge"
:ext/ns-alias {:ns 'vis.ext.search :alias 'search}
:ext/prompt "Document search tools (use search/ prefix):
- (search/find query) - full-text search across documents"
:ext/symbols [(ext/symbol 'find search-fn
{:doc "Full-text search."
:arglists '([query])
:examples ["(search/find \"neural\")"]})]}))
;; Self-register at load time
(ext/register-global! search-ext)
The LLM sees in the system prompt:
[namespace: search → vis.ext.search]
Document search tools (use search/ prefix):
- (search/find query) - full-text search across documents
And calls (search/find "neural") from :code blocks. Bare
(find "neural") does not resolve.
Sections
- Extension Spec - all keys, defaults, validation
- Hook Protocol -
:before-fn,:after-fn,:on-error-fn - Environment Map - every key in the environment
- Nudge System - built-in + extension nudges