| name | nodes-workflow |
| description | Implementation workflow for the newspack-nodes substrate (Node subclasses, topologies, deploys). Use when adding new node types, wiring topology files, or making changes that need to ride through the deploy → restart → verify cycle. |
| argument-hint | [node-type or feature] |
Newspack Nodes Workflow
This skill covers work inside newspack-nodes (the substrate).
Read AGENTS.md first for the architecture-decisions and key-files map; this skill is the procedural companion.
When to Use
- Adding a Node subclass to the substrate (something every consumer benefits from, not application-specific)
- Adding or modifying CommandInterpreter shell verbs
- Touching Worker / fleet-revival lifecycle code
- Any change that ships in
newspack-nodes/ and rides through the deploy + restart cycle
For application-side changes (RequestBuilder, FlameBuilder, REST controllers, dashboards), use the event-logger-nodes plugin's own workflow skill.
Phases
Phase 1: Locate the right layer
The boundary that matters: does this belong in the substrate? Substrate code is application-agnostic. Reaching for an event-logger-specific concept (request_id, firehose, flame) means you are in the wrong plugin — go to newspack-event-logger-nodes/.
Substrate-appropriate: a generic Filter node, a new TYPE flag, a Tail buffering mode, a Router heuristic, a file-writing primitive (Log), a routing helper (Echo), generic async-job dispatch (Job_Worker_Node). Substrate-inappropriate: a node that knows what a "request" is. The seam: generic job dispatch is substrate, but the per-job request context is application-side. Job_Worker_Node is the substrate's job seam — apps register handlers via the newspack_nodes/{job,remote_job}_handlers filters and hook per-job context via the newspack_nodes/job_worker/{before,after}_job actions. Extend job-dispatch-adjacent substrate code through those, and never pull request-aware code into Job_Worker_Node.
Phase 2: Implement
For a new Node subclass:
- Create
includes/class-{name}.php with class Foo_Node extends Node — every node class ends in _Node, and the shell-name in make_node <type> <name> is the class minus _Node, so callers type make_node Foo my_foo. Override fill( array $message ): void — that is the contract. Bump $this->counter and forward via $this->sink?->fill( $message ) unless you have a specific reason not to.
- v0.6.0 Tachikoma sequence: the ctor must be parameter-less. Declare positional config in
node_schema()['arguments'] as [{name, type, default?, required?}]; make_node instantiates with new $fqcn(), then calls name(), then arguments( $arg_tokens ) (the scalar ctor args as a flat token array — arguments( ?array $args ): array takes and returns list<string> argv, NOT a space-joined string), then sink( $this ). The base arguments() only stores the token array; a node that wants its schema args on props overrides arguments() and runs the tokens through parse_schema_args() (step 3), so config round-trips through dump_config(), which re-joins via Node::serialize_args().
- Override
arguments() only for declared positional config or derived state (e.g. Partition_Node's partition_dir). Follow the Partition_Node reference: if ( null === $args ) return parent::arguments(); (pure getter), else parse_schema_args( $args ) then derive. parse_schema_args() fills each missing token from its schema default or throws when the arg is required, so a bare make_node Foo fails loud instead of writing filesystem-root junk like /p0; the '' === $args short-circuit is gone, because args are a list. Per ADR-5, event-loop and filesystem work (set_timer, mkdir, fopen) stays OUT of both the ctor and for request-scope nodes (Topic/Partition) — file handles open lazily on first .
For a new CommandInterpreter verb:
- Add to
$H (help text) and $C (callable map) in init_C(). Aliases get their own $C row pointing at the same cmd_foo static; document them in the canonical verb's $H entry (alias: bar).
- Add a
cmd_foo() static method. Verb handlers receive the pre-split token array array $args (list<string> argv, normalized via arg_strings()), so parse positionals with [ $arg1, $arg2 ] = array_pad( $args, N, '' ) — no preg_split on a string. Classify --key=value and bare --key flags via Command_Args::parse( $args ).
- Intercept a purely shell-side verb (
cd, tell_node, send_node) in Shell::parse() instead — it never reaches interpreter dispatch. Document it in $H anyway so help covers everything the user can type.
- Throwing from
cmd_foo is fine — interpret() catches \Throwable and wraps the response as TM_COMMAND|TM_ERROR. Reserve return 'error: ...' for malformed-args paths that want the canonical OK response shape.
Phase 3: Test, restart, verify
cd tests && ../vendor/bin/phpunit --enforce-time-limit
npm run lint:php
npm run lint:phpstan
wp nodes restart all
wp nodes status
If the change also requires an application plugin to update — a substrate change affecting how the app's consumer attaches — redeploy that plugin.
Phase 4: Live-verify
For changes affecting the firehose pipeline, hit the dashboard or a real URL. The substrate itself ships wp nodes status and wp nodes cli; the application-side filter wp nodes reqgrep lives in newspack-event-logger-nodes and works only where that plugin is installed too (it is, in dndocker):
curl -sk "<site>/" -o /dev/null
wp nodes reqgrep --recent | head -10
Match what you see against your expectations. If something is off, the nodes-debugging skill walks through wp nodes cli for live introspection.
Patterns That Trip People Up
- Constructors must be event-loop-free for Topic and Partition (and anything else instantiated per request). No
set_timer, no Core::node() lookup, no scandir. AGENTS.md decision 5 carries the reason: the constructor runs in request scope, where no event loop exists to fire timers and the EF has drained nothing, so Core::node() returns null.
- FROM stamping is for I/O boundaries only. Consumer and HTTP_In stamp; internal nodes, Tail included, don't. Adding
stamp_message() to a Tee, a Hook, or Tail itself is probably wrong.
- Use
Message::new_message(), not []. It pre-populates the 7 indices with safe defaults; an uninitialized slot produces null-coalesce errors deep in Router or Dumper.
- Touching a substrate setting? Edit
Settings_Schema, not parallel lists. Since v0.13.0 the substrate declares each setting once as a Config_System\Field in class-settings-schema.php; Config (key-list, worker-restart classification) and the Admin settings page both derive from that schema (Config_System\Schema / Settings_Renderer / Options_Overlay). Add or change a setting in Settings_Schema; don't hand-maintain a second array. The admin surface is gated by the allowed_users config key via Admin::current_user_allowed() — route any admin or menu registration you add through that funnel.
- Don't reintroduce TM_PERSIST. Its removal was deliberate (AGENTS decision 3). If you think you need ack/cancel, you almost certainly don't — synchronous I/O at every boundary handles backpressure naturally. The one reply-control flag the substrate keeps is
Message::TM_NOREPLY (added v0.12.0): a Shell with want_reply(false) (topology load, script mode) ORs it onto commands and interpret() then suppresses the reply. That is what stops a worker's boot-topology command from bouncing a NOT_AVAILABLE to _output/<pid> on startup, and it is the only reply-control surface a verb author may touch.
After You Land
- Update AGENTS.md if the change altered an architecture decision or key file
- If the file count is creeping up, consider splitting a file rather than growing it
- Push to GitHub via the plugin's own remote — its git repo is independent of dndocker
Related Skills
nodes-debugging — live REPL, log paths, common gotchas while running
nodes-review — substrate contract checklist (run before merging)