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

Source: https://nodecode.ai/docs/cell-api
Updated: 2026-10-08

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](https://github.com/nodecode-ai/cells/tree/main/template), 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` |

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.

```sh
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.

```text
        ▄  ▄  ▄▄  ▄▄▄  ▄▄▄  ▄▄▄  ▄▄  ▄▄▄  ▄▄▄
        █▀▄█ █  █ █  █ █▄  █    █  █ █  █ █▄
        ▀  ▀  ▀▀  ▀▀▀  ▀▀▀  ▀▀▀  ▀▀  ▀▀▀  ▀▀▀
                   0.0.1+370099410

Tip Type / to browse commands; /help shows shortcuts.

            profile default · ~/.nodecode
            cells   weather
```

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

Mistakes to avoid:

- 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" ...)`.

**Mistake: `(defsystem "nodecode-template")`**

nodecode-weather.asd:

```text
(defsystem "nodecode-template"
  :depends-on ("nodecode")
  :components ((:file "cell")))
```

(describe-cells): cell nodecode-weather: FAILED Component "nodecode-weather" not found.

```text
  describe the cells

  (describe-cells)
  ⎿ cell nodecode-weather: FAILED Component "nodecode-weather" not found
    WARNING: System definition file #P"/home/mike/.nodecode/cells/nodecode-weather/nodecode-weather.asd"
  ⋮ +2 lines (Ctrl+O to expand)
    1
    [watching: shown again when its value changes]

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 0.9s completed · ↑559 c73.2% ↓16
```

**Instead: `(defsystem "nodecode-weather")`**

nodecode-weather.asd:

```text
(defsystem "nodecode-weather"
  :depends-on ("nodecode")
  :components ((:file "cell")))
```

The landing after a restart: profile default and cells weather.

```text
        ▄  ▄  ▄▄  ▄▄▄  ▄▄▄  ▄▄▄  ▄▄  ▄▄▄  ▄▄▄
        █▀▄█ █  █ █  █ █▄  █    █  █ █  █ █▄
        ▀  ▀  ▀▀  ▀▀▀  ▀▀▀  ▀▀▀  ▀▀  ▀▀▀  ▀▀▀
                   0.0.1+370099410

Tip Type / to browse commands; /help shows shortcuts.

            profile default · ~/.nodecode
            cells   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`.

**Mistake: `(defpackage #:weather)`**

cell.lisp:

```text
(defpackage #:weather
  (:use #:cl))
```

(describe-cells): cell nodecode-weather: library, 0 definitions. Nothing starts.

```text
  describe the cells

  (describe-cells)
  ⎿ cell nodecode-weather: library, 0 definitions
    1
    [watching: shown again when its value changes]

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 0.9s completed · ↑465 c76.7% ↓16
```

**Instead: `(defpackage #:nodecode-weather)`**

cell.lisp:

```text
(defpackage #:nodecode-weather
  (:use #:cl))
```

(describe-cells) through eval: cell nodecode-weather: peripheral, 0 definitions, started.

```text
  describe the cells

  (describe-cells)
  ⎿ cell nodecode-weather: peripheral, 0 definitions, started
    1
    [watching: shown again when its value changes]

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 0.9s completed · ↑141 c94.1% ↓16
```

## define-cell

The whole cell, as it ran for every screen on this page:

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

```text
■ Lisbon: 19°C

Ready
```

The model calls the weather tool with city Oslo, and the cell answers Oslo: 4°C.

```text
  weather in Oslo

  weather {"city": "Oslo"}
  ⎿ Oslo: 4°C

  Oslo is 4°C today.

• nodecode/nodecode-axolotl-cyber (max) · 0.9s completed · ↑475 c77.9% ↓16
```

`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](#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. |

(help :weather) through eval answers the manual's TEXT.

```text
  show the manual

  (help :weather)
  ⎿ "(nodecode-weather::today CITY) answers today's forecast."
    [watching: shown again when its value changes]

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.0s completed · ↑139 c96.1% ↓16
```

Everything is filed under one key: the package's name, downcased. That is the folder's name.

Mistakes to avoid:

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

**Mistake: `no define-peripheral`**

cell.lisp:

```text
(in-package #:nodecode-weather)

(nle:define-cell weather
  ...)
```

/weather Lisbon fails: The function NODECODE-WEATHER::RUNNING-SETTINGS is undefined.

```text
■ weather: The function NODECODE-WEATHER::RUNNING-SETTINGS is undefined.

Ready
```

**Instead: `(nlk:define-peripheral weather :not-running t)`**

cell.lisp:

```text
(in-package #:nodecode-weather)

(nlk:define-peripheral weather :not-running t)

(nle:define-cell weather
  ...)
```

/weather Lisbon answers: Lisbon: 19°C.

```text
■ Lisbon: 19°C

Ready
```

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

**Mistake: `no :section`**

cell.lisp:

```text
(nle:define-cell weather
  (:tool "weather" (nlk:contract 'forecast) 'look-up))
```

(describe-cells): cell nodecode-weather: FAILED COMPILE-FILE-ERROR while compiling cell.

```text
  describe the cells

  (describe-cells)
  ⎿ cell nodecode-weather: FAILED COMPILE-FILE-ERROR while compiling #<CL-SOURCE-FILE "nodecode-weather"
    "cell">
  ⋮ +18 lines (Ctrl+O to expand)
    1
    [watching: shown again when its value changes]

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 0.9s completed · ↑728 c67.7% ↓16
```

The same, expanded: define-cell WEATHER: a (:section (PATH...)...) clause is required.

```text
; caught ERROR:
;   (during macroexpansion of (NODECODE.EVOLVED:DEFINE-CELL WEATHER
;     ...))
;   define-cell WEATHER: a (:section (PATH...) ...) clause is required
```

**Instead: `(:section ("weather")...)`**

cell.lisp:

```text
(nle:define-cell weather
  (:section ("weather")
    ("units" :choice :options '("c" "f") :default "c"))
  (:tool "weather" (nlk:contract 'forecast) 'look-up))
```

(describe-cells) through eval: cell nodecode-weather: peripheral, 0 definitions, started.

```text
  describe the cells

  (describe-cells)
  ⎿ cell nodecode-weather: peripheral, 0 definitions, started
    1
    [watching: shown again when its value changes]

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 0.9s completed · ↑141 c94.1% ↓16
```

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

**Mistake: `(defun run-slash (args)...)`**

cell.lisp:

```text
(defun run-slash (args)
  (today args))
```

/weather Lisbon fails: invalid number of arguments: 2.

```text
■ weather: invalid number of arguments: 2

Ready
```

**Instead: `(defun run-slash (args session-id)...)`**

cell.lisp:

```text
(defun run-slash (args session-id)
  (declare (ignore session-id))
  (today args))
```

/weather Lisbon answers: Lisbon: 19°C.

```text
■ Lisbon: 19°C

Ready
```

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

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

```text
  weather in 3

  weather {"city": 3} failed
  ⎿ ERROR: tool preflight failed: {"code":"contract_refused","tool":"weather","expected":["city","a non-empty
    string"],"received":["city"],"repair":"forecast: city must be a non-empty string, got the integer 3; the
    tool's parameters schema names every member it declares","retryable":true}

  That call went wrong.

• nodecode/nodecode-axolotl-cyber (max) · 0.9s completed · ↑186 c94.3% ↓16
```

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

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

```text
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.

Mistakes to avoid:

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

**Mistake: `"units": "C"`**

config.jsonc:

```text
"weather": { "units": "C" }
```

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

```text
cell nodecode-weather not started:     ×
config refusal: units must be one of
c|f, got "C"
```

**Instead: `"units": "c"`**

config.jsonc:

```text
(config-set '("weather") '(:units "c"))
(restart-cells "nodecode-weather")
```

config-set fixes units, and (restart-cells "nodecode-weather") answers started, without a relaunch.

```text
  fix the units

  (config-set '("weather") '(:units "c"))
  ⎿ weather: set units (0 added, 1 replaced)

  (restart-cells "nodecode-weather")
  ⎿ cell nodecode-weather: peripheral, 0 definitions, started

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.7s completed · ↑229 c93.7% ↓38
```

- 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)`.

**Mistake: `(setting :timeout_seconds)`**

cell.lisp:

```text
(setting :timeout_seconds)
```

(setting :timeout_seconds) fails: no setting :TIMEOUT_SECONDS; this cell declares :UNITS :CITIES :TIMEOUT-SECONDS.

```text
  read timeout_seconds

  (nodecode-weather::setting :timeout_seconds) failed
  ⎿ ERROR: SIMPLE-ERROR: no setting :TIMEOUT_SECONDS; this cell declares :UNITS :CITIES :TIMEOUT-SECONDS

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.0s completed · ↑141 c94.7% ↓24
```

**Instead: `(setting :timeout-seconds)`**

cell.lisp:

```text
(setting :timeout-seconds)
```

(setting :timeout-seconds) answers 10.

```text
  read timeout-seconds

  (nodecode-weather::setting :timeout-seconds)
  ⎿ 10
    [watching: shown again when its value changes]

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.1s completed · ↑128 c95.7% ↓24
```

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

**Mistake: `:required t`**

cell.lisp:

```text
("key_env" :env :required t)
```

With a :required value absent the cell starts, and section-needs-setup-p answers T.

```text
  needs setup

  (nlk:section-needs-setup-p (nlk::find-section '("weather")) (read-shared-config))
  ⎿ T
    [watching: shown again when its value changes]

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.2s completed · ↑506 c76.8% ↓35
```

**Instead: `(fail ...)`**

cell.lisp:

```text
(or (setting :key-env)
    (fail "weather: set key_env in the weather section"))
```

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

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

```text
  count lisp files

  Listed **/*.lisp failed
  ⎿ ERROR: eval is off while the weather cell runs

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.0s completed · ↑492 c77.3% ↓21
```

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.

Mistakes to avoid:

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

**Mistake: `(declare (ignore next))`**

cell.lisp:

```text
(: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.

```text
  count lisp files

  Listed **/*.lisp

 ■ Error: turn failed: The value of NODECODE.KERNEL::RESULT is NIL, which is not of type STRING.

× nodecode/nodecode-axolotl-cyber (max) · 0.6s failed · ↑394 c61.9% ↓13
```

**Instead: `(funcall next op)`**

cell.lisp:

```text
(:hook :tool (lambda (op next)
              (log-call op)
              (funcall next op)))
```

The model calls the weather tool with city Oslo, and the cell answers Oslo: 4°C.

```text
  weather in Oslo

  weather {"city": "Oslo"}
  ⎿ Oslo: 4°C

  Oslo is 4°C today.

• nodecode/nodecode-axolotl-cyber (max) · 0.9s completed · ↑475 c77.9% ↓16
```

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

**Mistake: `"ERROR: ..."`**

cell.lisp:

```text
"ERROR: eval is off while the weather cell runs"
```

The model calls eval; the hook's refusal string comes back, and the call is drawn as done.

```text
  count lisp files

  Listed **/*.lisp

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.0s completed · ↑492 c77.3% ↓21
```

**Instead: `(nle:failure "ERROR: ...")`**

cell.lisp:

```text
(nle:failure "ERROR: eval is off while the weather cell runs")
```

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

```text
  count lisp files

  Listed **/*.lisp failed
  ⎿ ERROR: eval is off while the weather cell runs

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.0s completed · ↑492 c77.3% ↓21
```

## Notices and restarts

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

A toast: weather: no key set, answering from the table.

```text
  weather: no key set, answering from    ×
  the table
```

What the model's next prompt carried:

```text
<harness key="notices">
weather: no key set, answering from the table
</harness>

hello
```

Mistakes to avoid:

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

**Mistake: `(nle:notice TEXT)`**

cell.lisp:

```text
(nle:notice "weather: no key set, answering from the table"
            :level :warning)
```

What the model reads next:

```text
hello
```

**Instead: `(nle:notice TEXT :key "nodecode-weather")`**

cell.lisp:

```text
(nle:notice "weather: no key set, answering from the table"
            :level :warning :key "nodecode-weather")
```

What the model reads next:

```text
<harness key="notices">
weather: no key set, answering from the table
</harness>

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.

**Mistake: `no clear on stop`**

cell.lisp:

```text
(nle:notice "weather: no key set, answering from the table"
            :level :warning :key "nodecode-weather")
```

Turned off and restarted, the cell's notice still stands in the report.

```text
  turn weather off

  (config-set '("weather") '(:enabled :false))
  ⎿ weather: set enabled (1 added, 0 replaced)

  (restart-cells "nodecode-weather")
  ⎿ cell nodecode-weather: peripheral, 0 definitions, started
    weather: no key set, answering from the table

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.7s completed · ↑251 c93.7% ↓39
```

**Instead: `(nle:notice nil :key "nodecode-weather")`**

cell.lisp:

```text
(nle:notice "weather: no key set, answering from the table"
            :level :warning :key "nodecode-weather")
(nle:on-stop (lambda () (nle:notice nil :key "nodecode-weather")))
```

Turned off and restarted: the report lists no notice.

```text
  turn weather off

  (config-set '("weather") '(:enabled :false))
  ⎿ weather: set enabled (1 added, 0 replaced)

  (restart-cells "nodecode-weather")
  ⎿ cell nodecode-weather: peripheral, 0 definitions, started

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.7s completed · ↑608 c82.6% ↓39
```

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

```lisp
(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
```

Mistakes to avoid:

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

**Mistake: `(nlk:worker-start ...)`**

cell.lisp:

```text
(:start (lambda ()
          (nlk:worker-start "weather" #'refresh :wake 900)))
```

After two restarts, three weather threads run.

```text
  count weather threads

  (count-if (lambda (th) (search "weather" (sb-thread:thread-name th))) (sb-thread:list-all-threads))
  ⎿ 3
    [watching: shown again when its value changes]

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.3s completed · ↑145 c94.7% ↓41
```

**Instead: `(nle:on-stop ... (nlk:worker-stop worker))`**

cell.lisp:

```text
(:start (lambda ()
          (let ((worker (nlk:worker-start "weather" #'refresh :wake 900)))
            (nle:on-stop (lambda () (nlk:worker-stop worker))))))
```

After two restarts, one weather thread runs.

```text
  count weather threads

  (count-if (lambda (th) (search "weather" (sb-thread:thread-name th))) (sb-thread:list-all-threads))
  ⎿ 1
    [watching: shown again when its value changes]

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.3s completed · ↑144 c96.7% ↓41
```

- 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 ...)`.

**Mistake: `nle:on-stop in a command`**

cell.lisp:

```text
(defun run-slash (args session-id)
  (let ((worker (nlk:worker-start "weather-once" #'refresh)))
    (nle:on-stop (lambda () (nlk:worker-stop worker))))
  ...)
```

/weather Lisbon fails: on-stop: no (starting ...) scope is open on this thread.

```text
■ weather: on-stop: no (starting ...) scope is open on this thread

Ready
```

**Instead: `nle:on-stop in :start`**

cell.lisp:

```text
(: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.

```text
■ Lisbon: 19°C

Ready
```

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

**Mistake: `(lambda () (nlk:worker-stop worker))`**

cell.lisp:

```text
(lambda ()
  (nlk:worker-stop worker))
```

Turned off, and /weather Lisbon still answers Lisbon: 19°C.

```text
  turn weather off

  (config-set '("weather") '(:enabled :false))
  ⎿ weather: set enabled (1 added, 0 replaced)

  (restart-cells "nodecode-weather")
  ⎿ cell nodecode-weather: peripheral, 0 definitions, started

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.7s completed · ↑564 c82.0% ↓39

 ■ Lisbon: 19°C

 Completed
```

**Instead: `(nle:unregister-commands "nodecode-weather")`**

cell.lisp:

```text
(lambda ()
  (nle:unregister-commands "nodecode-weather")
  (nlk:worker-stop worker))
```

With the cell off, /weather is an unknown command.

```text
■ unknown command /weather — /help lists commands

/weather Lisbon
```

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

Redefining nlk:home through eval fails: Lock on package NODECODE.KERNEL violated.

```text
  redefine home

  Defined nlk:home failed
  ⎿ ERROR: SYMBOL-PACKAGE-LOCKED-ERROR: Lock on package NODECODE.KERNEL violated when setting fdefinition of
    HOME while in package NODECODE.EVOLVED.
    See also:
      The SBCL Manual, Node "Package Locks"

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.2s completed · ↑182 c96.1% ↓37
```

A cell reads its home through `(nlk:home "...")`, never a literal `~/.nodecode/`. It then follows a [profile](https://nodecode.ai/docs/profiles).

Dependencies the image lacks go in `vendor/`, from `ql:bundle-systems`. `vendor/bundle.lisp` loads before the folder's systems.

Mistakes to avoid:

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

**Mistake: `(defun nlk:home ...)`**

cell.lisp:

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

```text
~/project
❯ nodecode
nodecode: could not start: Can't create directory /srv/weather

~/project
❯
```

**Instead: `nodecode --pristine`**

In a shell:

```text
nodecode --pristine
```

nodecode --pristine: (describe-cells) answers that nothing is kept.

```text
  describe the cells

  (describe-cells)
  ⎿ [pristine: nothing is kept; (rollback N) restores a sealed generation, or relaunch without --pristine]
    0
    [watching: shown again when its value changes]

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 0.9s completed · ↑481 c76.1% ↓16
```

- Writing `~/.nodecode/` into a path. Under a profile it still points at the main home. Instead: `(nlk:home "weather.cache")` follows the profile.

**Mistake: `"~/.nodecode/"`**

cell.lisp:

```text
(merge-pathnames "weather.cache" "~/.nodecode/")
```

In nodecode -p work, the cache path is ~/.nodecode/weather.cache, the main home's.

```text
  where is the cache

  (nlk:home-abbreviated (sb-ext:native-namestring (nodecode-weather::cache-file)))
  ⎿ "~/.nodecode/weather.cache"
    [watching: shown again when its value changes]

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.2s completed · ↑514 c76.6% ↓35
```

**Instead: `(nlk:home "weather.cache")`**

cell.lisp:

```text
(nlk:home "weather.cache")
```

In nodecode -p work, (nlk:home "weather.cache") is ~/.nodecode/profiles/work/weather.cache.

```text
  where is the cache

  (nlk:home-abbreviated (sb-ext:native-namestring (nodecode-weather::cache-file)))
  ⎿ "~/.nodecode/profiles/work/weather.cache"
    [watching: shown again when its value changes]

  Done.

• nodecode/nodecode-axolotl-cyber (max) · 1.2s completed · ↑518 c76.5% ↓35
```

## Test and share

| 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](https://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](https://nodecode.ai/docs/troubleshooting#cell-failures).
