Reference·Cell APIIndexClose
Cell API
How a cell is written: nle:define-cell, its clauses, tool contracts, the config section, hooks, notices, stop, and what Nodecode shows when one is written wrong.
One nle:define-cell form declares a cell: its config section, what it adds, and how all of it comes back off. The page builds one cell, nodecode-weather: a tool, a slash command and a manual. Each section ends with the mistakes people make there. Press Mistake and Instead to see what Nodecode showed each time.
Start from the template
The template ships with every release. It is also template/ in nodecode-ai/cells, with every cell Nodecode ships as a worked example.
| Where | Path |
|---|---|
| A release | ~/.local/share/nodecode/current/src/cells/template/ |
| GitHub | template/ in nodecode-ai/cells |
- Copy it to
~/.nodecode/cells/nodecode-<name>/. - Rename the
.asdfile and thedefsysteminside it. - Name the package after the system.
- Write one
nle:define-cellform undernlk:define-peripheral. - Start Nodecode. The landing lists the cell.
cp -r ~/.local/share/nodecode/current/src/cells/template ~/.nodecode/cells/nodecode-weather
cd ~/.nodecode/cells/nodecode-weather
mv nodecode-template.asd nodecode-weather.asd

| Part | Content |
|---|---|
nodecode-template.asd | The system. Depends on nodecode. |
cell.lisp | One section, one :tool hook, /template [NAME], one notice at start. |
config.example.jsonc | The section, commented. Nodecode does not read it. |
Renaming the .asd file and keeping the system inside it
ASDF finds no system named after the file. The folder fails its load, and the landing shows a failed row.
Instead: Rename both: nodecode-weather.asd holds (defsystem "nodecode-weather" ...).

Naming the package after the cell: weather
The loader looks for START-CELL in the package named after the system. It finds none, so the folder loads as a library and nothing starts.
Instead: Name the package after the system: nodecode-weather.
define-cell
The whole cell, as it ran for every screen on this page:
(defpackage #:nodecode-weather
(:use #:cl))
(in-package #:nodecode-weather)
(nlk:define-peripheral weather :not-running t)
(defparameter *today* '(("oslo" . 4) ("lisbon" . 19) ("tokyo" . 15)))
(nlk:define-contract forecast
(:doc "Today's forecast for one city.")
("city" :text :required t :doc "the city, as the operator wrote it"))
(defun today (city)
(let ((c (cdr (assoc (string-downcase city) *today* :test #'string=))))
(cond ((null c) (format nil "~a: no forecast" city))
((string= (setting :units) "f") (format nil "~a: ~d°F" city (round (+ 32 (* 9/5 c)))))
(t (format nil "~a: ~d°C" city c)))))
(defun look-up (request)
(today (forecast-city request)))
(defun run-slash (args session-id)
(declare (ignore session-id))
(today (if (plusp (length args)) args (or (first (setting :cities)) "Oslo"))))
(defun refresh () nil)
(nle:define-cell weather
(:section ("weather")
(:guide "units is c or f; cities is what /weather answers for with no argument")
("units" :choice :options '("c" "f") :default "c" :doc "degrees")
("cities" :list :doc "what the primer names")
("timeout_seconds" :integer :default 10 :min 1 :doc "one lookup's ceiling"))
(:tool "weather" (nlk:contract 'forecast) 'look-up)
(:help :weather "weather: today's forecast for a city - (weather:today \"Oslo\")"
"(nodecode-weather::today CITY) answers today's forecast.")
(:command "weather" 'run-slash
:description "Today, where you are" :argument-hint "CITY")
(:start (lambda ()
(let ((worker (nlk:worker-start "weather" #'refresh :wake 900)))
(nle:on-stop (lambda () (nlk:worker-stop worker)))))))


nlk:define-peripheral gives the package its condition, FAIL, the settings variable *WEATHER*, RUNNING-SETTINGS and DEFINE-VERB. nle:define-cell adds START-CELL, STOP-CELL and SETTING.
Clauses, in any order. They install in the order written and come off in reverse.
| Clause | Does | On stop |
|---|---|---|
(:section (PATH...) CLAUSE...) | Required. nlk:define-section, verbatim. | — |
(:settings FUNCTION) | (lambda (values table) ...) answers the plist the cell runs on, for a section the declaration cannot carry. | — |
(:tool NAME SCHEMA FN) | nle:register-tool. SCHEMA is a contract. See Tool arguments. | Unregistered. |
(:command NAME RUN &key ...) | /NAME in the one slash catalog. RUN takes (args session-id) and answers (values TEXT DIALOG). | Out of the catalog. |
(:help TOPIC SUMMARY TEXT) | (help TOPIC) answers TEXT. Every request's help section carries SUMMARY. | Topic removed. |
(:hook NAME FN) | nle:hook on a point or a function name. | Unhooked. |
(:route PATH FN &key public) | PATH answered on the gateway's own port, to the operator's token; to anyone under :public. A PATH ending in / answers everything under it. | Route removed. |
(:start FUNCTION) | (lambda () ...), with the settings set. Everything no clause covers. | What it gave nle:on-stop. |
A :command takes these keys:
| Key | Does |
|---|---|
:description | Its line in the slash menu. |
:argument-hint | Shown after /NAME while it is typed. |
:label, :catches | A :catches condition answers LABEL: it. Defaults: the command's name, and error. |
:session | nil for a command that acts on no session. |
:trigger | One character that opens it at a word's start. |
:complete | Takes (text session-id) and answers (:name :value) plists while the argument is typed. |

Everything is filed under one key: the package's name, downcased. That is the folder's name.
Leaving out nlk:define-peripheral
The folder loads and starts. The first call that reads a setting fails.
Instead: Put (nlk:define-peripheral weather :not-running t) above define-cell.

Leaving out the :section clause
define-cell requires one. The file does not compile, and the folder fails.
Instead: Declare the section the cell reads.
Giving a command's RUN one parameter
RUN is called with the argument text and the session id.
Instead: Take both, and ignore the one you don't use.
Tool arguments
A tool's arguments cross into the cell through a contract, declared once. The model is shown the schema it derives. A call that does not fit is refused before any hook or body runs, and the body reads a record.
(nlk:define-contract forecast
(:doc "Today's forecast for one city.")
("city" :text :required t :doc "the city, as the operator wrote it"))
(defun look-up (request)
(today (forecast-city request)))
(:tool "weather" (nlk:contract 'forecast) 'look-up)

| Declares | Detail |
|---|---|
| Types | :any, :string, :text (non-empty), :integer, :number, :boolean, :object, :array. |
| A member | :required, :default, :options (the schema's enum), :of to nest, :slot to rename. |
| Unknown members | Ignored, unless (:unknown :refuse). |
| Derives | FORECAST, FORECAST-CITY, MAKE-FORECAST, FORECAST-P, DECODE-FORECAST, ENCODE-FORECAST. |
| Arrives | Result |
|---|---|
JSON null | Absent. Takes the default, or is refused like a missing key. |
false | Present. NIL on a :boolean. |
| The wrong type | Refused, never coerced. "3" is not an integer. |
| A malformed declaration | Fails at macroexpansion, so the folder fails its load. |
The section
A section is the cell's part of ~/.nodecode/config.jsonc, declared once, as data. The setup panel, the model's setup primer, the cell's line in /cells, its refusal and the values it runs on all come from it.
| Field type | Value |
|---|---|
:string | Text. |
:secret | A secret written inline. |
:env | The name of an environment variable holding a secret. |
:path | A file path. |
:boolean | true or false. |
:integer | A whole number. May carry :min. |
:list | An array of strings. May carry :choices, a function of the section's table answering ((id . label) ...). |
:choice | One of :options. |
A field may be :required and carry :default and :doc. Section options: (:guide TEXT), (:check FN), (:one-of NAME...), (:any-of NAME...).
| Case | Result |
|---|---|
| Section present | The cell starts. |
"enabled": false | Nothing installs. The code stays loaded until Nodecode restarts. |
| A value of the wrong shape | NLK:CONFIG-REFUSAL. A toast in every shell, REFUSED on the cell, and a standing notice under the folder's name. |
A :required value absent | No refusal. The setup panel asks for it. |

(setting :timeout-seconds) reads a value. An underscore in the config is a dash here. (config-get '("weather")) reads the section, and (config-set '("weather") '(:units "c")) writes it, comments kept.
Writing a value the section does not allow: "units": "C"
The cell refuses to start. Every shell gets a toast, and the cell is REFUSED until it restarts.
Instead: Fix the value, then (restart-cells "nodecode-weather"). No relaunch.

Reading a setting by its config spelling: :timeout_seconds
SETTING names keys with dashes. A key the section does not declare is an error.
Instead: (setting :timeout-seconds).
Marking a field :required and counting on a refusal when it is missing
Nothing refuses. The cell starts, and the setup panel asks for the value.
Instead: Check the value where you use it, and FAIL with a sentence that says what to set.
Hooks
| NAME | FN receives |
|---|---|
:tool | (op next). One tool dispatch. OP is (:name :arguments :call-id). |
:frame | (op next). One live frame leaving the gateway. |
:credential | (op next). |
| A function name | (next &rest args). Around every call of it. |
Call next to run the rest of the chain. Don't, to replace it. A hook that refuses a call answers (nle:failure "ERROR: ..."), and the call is recorded failed.

Two cells hooking one function under different keys both run, in load order. (nle:turn) answers the running turn as a plist, or NIL outside one: :session-id, :turn-id, :parent, what it has spent (:seconds, :rounds, :calls, the token counts) and its :budget. To cap your own sessions' turns, advise nle:turn-budget and return (:seconds N :calls N :tokens N). Call next for every other session.
Writing a :tool hook that never calls next
The hook replaces every tool call in every session. It answers NIL, so the turn fails.
Instead: (funcall next op), unless you mean to replace the call. Then return a string.

Refusing a call by returning "ERROR: ..."
The call is recorded as done. The transcript draws it as a success.
Instead: Return (nle:failure "ERROR: ..."). The call is recorded failed.
Notices and restarts
(nle:notice "weather: no key set, answering from the table" :level :warning :key "nodecode-weather")
(nle:notice nil :key "nodecode-weather") ; clear it
(restart-cells) ; every peripheral, from the config as it is now
(restart-cells "nodecode-weather") ; one folder
| Notice | Shown |
|---|---|
:info, :warning | A toast for five seconds. |
:error | A toast until it is closed. |
With :key | Stands. A shell that attaches later gets it, and the model reads it at the tail of its prompt until it is replaced or cleared. |

What the model's next prompt carried:
<harness key="notices">
weather: no key set, answering from the table
</harness>
hello
Posting a state that lasts without :key
It is said once, as a toast. The model never reads it.
Instead: Give it a :key. It stands, and the model reads it until you clear it.
Posting a keyed notice at start and never clearing it
It stands after the cell stops. The restart report still lists it.
Instead: Clear it on stop, next to where you post it.
Stop
| Property | Detail |
|---|---|
| Complete | Stop takes back exactly what start put in. |
| Idempotent | A second stop does nothing. |
| A start that fails halfway | Leaves nothing. Sibling cells still start. |
| A second start | Stops the first one. One of everything remains. |
When the declaration does not fit, write START-CELL by hand from the same pieces:
(nlk:define-section ("mine") ...) ; config alone, no cell
(nle:register-tool NAME SCHEMA FN) ; (nle:unregister-tool NAME)
(nle:register-command OWNER CMD RUN ...) ; (nle:unregister-commands OWNER)
(nle:hook NAME KEY FN) ; (nle:unhook NAME KEY)
(nle:route PATH FN) ; (nle:route PATH nil)
(nlk:worker-start NAME STEP :wake N) ; (nlk:worker-stop W), (nlk:worker-poke W)
(nle:starting ...) ; answers the thunk that undoes its body
(nle:on-stop FN) ; one inverse, in the innermost scope
Starting a worker in :start without an on-stop
Each restart starts another worker. The old ones keep running.
Instead: Record its stop with nle:on-stop.

Calling nle:on-stop from a command or a tool
There is no start to record it in, so it signals. Whatever it was meant to stop keeps running.
Instead: Start long-lived things in :start, or inside (nle:starting ...).
Writing START-CELL by hand and leaving the command out of its stop
After the cell stops, /weather still answers.
Instead: Use define-cell. By hand, unregister everything you registered.
What a folder may do
| Act | Detail |
|---|---|
| Define | New names in nodecode.evolved or its own package. |
| Override | A shipped nodecode.evolved definition. The boot line reports it. |
| Advise | A shipped function, through a hook. Preferred to an override. |
| Add methods | To shipped generics. Only a restart removes one. |
Redefine a nodecode.kernel name | Don't. The kernel locks after the folders load, so at load the folder's definition wins, in every process. Through eval, the lock refuses it. |

A cell reads its home through (nlk:home "..."), never a literal ~/.nodecode/. It then follows a profile.
Dependencies the image lacks go in vendor/, from ql:bundle-systems. vendor/bundle.lisp loads before the folder's systems.
Redefining a kernel function in the folder
The folder's definition wins in every process. Here, nlk:home points at a directory that does not exist, and Nodecode no longer starts.
Instead: Advise the function with a hook. If a launch already fails, run nodecode --pristine and fix the folder.

Writing ~/.nodecode/ into a path
Under a profile it still points at the main home.
Instead: (nlk:home "weather.cache") follows the profile.
| To | How |
|---|---|
| Share it | git push the folder to any remote. |
| Install it elsewhere | git clone it into ~/.nodecode/cells/, or nodecode add nc://NAME once it is on the hub. |
| List it on the hub | Submit it at nodecode.ai/cells/submit. Nodecode's own cells arrive as pull requests to nodecode-ai/cells, where .github/test-cell.sh FOLDER runs one cell's tests. |
| Undo a change | nodecode rollback N puts every folder back to generation N. A folder's git HEAD is its revision. A folder with no repository is never restored, and the rollback refuses by name. |
When one goes wrong, find the line in Troubleshooting.