Reference·Cell APIIndex

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.

.mdllms.txt

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.

WherePath
A release~/.local/share/nodecode/current/src/cells/template/
GitHubtemplate/ in nodecode-ai/cells
  1. Copy it to ~/.nodecode/cells/nodecode-<name>/.
  2. Rename the .asd file and the defsystem inside it.
  3. Name the package after the system.
  4. Write one nle:define-cell form under nlk:define-peripheral.
  5. Start Nodecode. The landing lists the cell.
a shell
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
The landing after a restart: profile default and cells weather.
PartContent
nodecode-template.asdThe system. Depends on nodecode.
cell.lispOne section, one :tool hook, /template [NAME], one notice at start.
config.example.jsoncThe 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" ...).

nodecode-weather.asd
(defsystem "nodecode-template"
:depends-on ("nodecode")
:components ((:file "cell")))
(describe-cells): cell nodecode-weather: FAILED Component "nodecode-weather" not found.

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)))))))
/weather Lisbon answers: Lisbon: 19°C.
The model calls the weather tool with city Oslo, and the cell answers Oslo: 4°C.

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.

ClauseDoesOn 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:

KeyDoes
:descriptionIts line in the slash menu.
:argument-hintShown after /NAME while it is typed.
:label, :catchesA :catches condition answers LABEL: it. Defaults: the command's name, and error.
:sessionnil for a command that acts on no session.
:triggerOne character that opens it at a word's start.
:completeTakes (text session-id) and answers (:name :value) plists while the argument is typed.
(help :weather) through eval answers the manual's TEXT.

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.

cell.lisp
(in-package #:nodecode-weather)
(nle:define-cell weather
...)
/weather Lisbon fails: The function NODECODE-WEATHER::RUNNING-SETTINGS is undefined.

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)
The model sends the number 3 for city; the call is refused before the body runs, contract_refused, with a repair.
DeclaresDetail
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 membersIgnored, unless (:unknown :refuse).
DerivesFORECAST, FORECAST-CITY, MAKE-FORECAST, FORECAST-P, DECODE-FORECAST, ENCODE-FORECAST.
ArrivesResult
JSON nullAbsent. Takes the default, or is refused like a missing key.
falsePresent. NIL on a :boolean.
The wrong typeRefused, never coerced. "3" is not an integer.
A malformed declarationFails 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 typeValue
:stringText.
:secretA secret written inline.
:envThe name of an environment variable holding a secret.
:pathA file path.
:booleantrue or false.
:integerA whole number. May carry :min.
:listAn array of strings. May carry :choices, a function of the section's table answering ((id . label) ...).
:choiceOne of :options.

A field may be :required and carry :default and :doc. Section options: (:guide TEXT), (:check FN), (:one-of NAME...), (:any-of NAME...).

CaseResult
Section presentThe cell starts.
"enabled": falseNothing installs. The code stays loaded until Nodecode restarts.
A value of the wrong shapeNLK:CONFIG-REFUSAL. A toast in every shell, REFUSED on the cell, and a standing notice under the folder's name.
A :required value absentNo refusal. The setup panel asks for it.
A toast in every shell: cell nodecode-weather not started: config refusal: units must be one of c|f, got "C".

(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.

config.jsonc
"weather": { "units": "C" }
A toast in every shell: cell nodecode-weather not started: config refusal: units must be one of c|f, got "C".

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

NAMEFN 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.

The model calls eval; the hook refuses with nle:failure, and the call is drawn failed with the reason.

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.

cell.lisp
(:hook :tool (lambda (op next)
(declare (ignore next))
(log-call op)))
Any tool call: Error: turn failed: The value of NODECODE.KERNEL::RESULT is NIL, which is not of type 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
NoticeShown
:info, :warningA toast for five seconds.
:errorA toast until it is closed.
With :keyStands. A shell that attaches later gets it, and the model reads it at the tail of its prompt until it is replaced or cleared.
A toast: weather: no key set, answering from the table.

What the model's next prompt carried:

it prints
<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.

cell.lisp
(nle:notice "weather: no key set, answering from the table"
:level :warning)
What the model reads next
hello

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

PropertyDetail
CompleteStop takes back exactly what start put in.
IdempotentA second stop does nothing.
A start that fails halfwayLeaves nothing. Sibling cells still start.
A second startStops 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.

cell.lisp
(:start (lambda ()
(nlk:worker-start "weather" #'refresh :wake 900)))
After two restarts, three weather threads run.

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

ActDetail
DefineNew names in nodecode.evolved or its own package.
OverrideA shipped nodecode.evolved definition. The boot line reports it.
AdviseA shipped function, through a hook. Preferred to an override.
Add methodsTo shipped generics. Only a restart removes one.
Redefine a nodecode.kernel nameDon'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.
Redefining nlk:home through eval fails: Lock on package NODECODE.KERNEL violated.

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.

cell.lisp
(defun nlk:home (&optional (relative ""))
(merge-pathnames relative "/srv/weather/"))
The launch paints the landing, then stops: nodecode: could not start: Can't create directory /srv/weather. The shell's prompt comes back.

Writing ~/.nodecode/ into a path

Under a profile it still points at the main home.

Instead: (nlk:home "weather.cache") follows the profile.

Test and share

ToHow
Share itgit push the folder to any remote.
Install it elsewheregit clone it into ~/.nodecode/cells/, or nodecode add nc://NAME once it is on the hub.
List it on the hubSubmit 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 changenodecode 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.

Esc

    Loading the index…

    ↑↓ moveEnter openEsc close