Behavior surface
Source:
harness/docs/plugins/polish.mdStatus: current — source staged for documentation review.
Behavior surface
Section titled “Behavior surface”Author contract for a pack the Arrival behavior capability executes
(packages/harness-server/src/plugin-surface). Author-facing forms are macros.
Law lives in the
plugins/hooks-notify-vs-transform-2026-09-20.md. This page is the shape
you write. The session loop stores the pin and joins notes; it does not dispatch
these programs. Host builtins that still construct InhumanPlugin are described in
plugins/tldr.md; that bag is not how new behavior is selected.
A pack does not register one program for the whole kernel. A bot’s .prompt
frontmatter selects behavior.program. The running agent pins that source,
its configuration, and its receiver grants. Another bot that loads the same
file gets its own registry.
1. plugin.json
Section titled “1. plugin.json”Under extensions["tools.inhuman"]:
| Field | Role |
|---|---|
bots |
.prompt files. Behavior is selected in frontmatter, not by a package-level program. |
skills |
Skill directories, jailed to the package root. |
states |
Optional session, project, and global Scheme modules. Each defines one reducer and its methods. |
mcp |
Records the resolver keeps as name and visibility. command, args, and scope are accepted and are not a spawn. A private stdio server still comes from InhumanPlugin.mcpServers. |
commands |
Host slash-commands. Not model tools and not behavior rows. |
plugins |
Still parsed. The pack loader does not import it. Host builtins construct InhumanPlugin themselves. New behavior does not add a row here. |
Unknown state scopes are a load error. A path that escapes the package root or
names a missing file is a load error. A states-only pack with no bot is valid.
A bot with no behavior block gets the standard host environment and registers nothing.
interceptingHooks and notifyingHooks are not executed. The manifest schema
drops them.
{ "extensions": { "tools.inhuman": { "bots": ["./bots/examiner.prompt"], "states": { "session": "./states/session.scm", "project": "./states/project.scm" }, "mcp": [{ "name": "memory", "visibility": "private" }] } }}2. Bot frontmatter
Section titled “2. Bot frontmatter”---tools: [read_file, grep]behavior: program: ../behaviors/examiner/plugin.scm configuration: requireVerdict: true bindings: offload: plugin: example scope: project methods: [get-memory, search]---attach: foreground makes the bot the foreground agent’s behavior instead of a
named agent. The host config opts one plugin out with
plugins.<name>.foreground: false. Every other bot becomes a named agent;
scope is session (default), project, or global, and names where one
instance lives. A foreground bot has no scope. The file stem is the agent name.
program is required when behavior is present. Paths resolve from the bot
file and stay inside the package. configuration is JSON data, validated by
validate/configuration before the agent works. bindings name receivers.
The host pins the owner id and the method list. Arguments cannot widen that
grant or point at a different owner.
environment (a JS module run in the host) is not supported. A bot that
declares one does not attach, and the plugin reports the problem. JS in the
host stays the internal plugin API; keep the behavior in Scheme.
Template holes cannot replace program, environment, or bindings. A child
bot selects its own behavior. It does not inherit the caller’s hooks.
3. Registration
Section titled “3. Registration”Top-level forms register. Bodies do not run. The same forms inside a callback
are a load error (callback-time registration).
| Form | Records |
|---|---|
define/tool |
A procedure, a model tool, or both. 'tool and 'both require :description and :input. 'procedure stays out of the model catalog. :input, when present, must be a schema. |
guard/read guard/write guard/shell guard/edit guard/mcp guard/agent guard/turn guard/round |
Critical-path middleware. :middleware receives raw values and a single-use invoke. |
hook/read hook/write hook/shell hook/edit hook/mcp hook/agent hook/turn hook/round |
Parallel :pre, :post, and :post-fail. Filtered values only. |
define/reducer |
One :initial, one :reduce, optional :host-only. A second reducer in the module is a load error. |
define/method |
:kind query or command, :input, :output, :handler. Methods are not model tools. |
subscribe |
A named outbound subscriber. |
require |
Another file, resolved from the current one, jailed, deduplicated by path. |
validate/configuration |
A pure predicate over configuration. #f blocks activation. |
A hook keyword on a guard, or :middleware on a hook, is a load error.
:reentrant-depth is a nonnegative integer and defaults to 0. Unknown keywords
are a load error. guard/read and guard/write require matchers.
:replace is a guard capability and needs :contract redaction or summarize.
There is no :short-circuit. The core is what originates success.
Reducer and method bodies must not call JSON.parse. Schema repair is host work.
The loader rejects a handler whose source contains it.
4. Guards and hooks
Section titled “4. Guards and hooks”(guard/shell :capabilities 'replace :contract 'redaction :middleware (lambda (raw invoke) (invoke (redact raw))))
(hook/shell :post (lambda (command result) "formatted locally") :post-fail (lambda (command failure) "diagnostic"))The host matches globs before Scheme runs. A miss does not enter the middleware.
The chain is guards, then the floor, then the core. Tool :pre, :post, and
:post-fail hooks see the value that reached the floor. Turn and round hooks
receive the boundary record passed into dispatch. A guard that does not invoke must
return (refuse "…"). A later guard cannot drop a mark an earlier guard placed.
guard/retry restarts the chain from the raw value under a finite budget.
The budget ends as a refusal. invoke is single-use, including a second call
after the core has returned. A throw after a successful invoke keeps the core
result and surfaces the failure.
A broken guard fails closed. A broken hook fails open: its error is collected, and the primary result stays.
Behavior does not widen the bot’s effect ceiling. A tool the ceiling omits does not enter.
5. State
Section titled “5. State”(define/reducer :initial (lambda () (dict :body "" :revision 0)) :reduce (lambda (state action) state) :host-only (lambda (action) (equal? (:type action) "verdict")))
(define/method 'put :kind 'command :input (z/object (dict :text z/string)) :output z/string :handler (lambda (input state) (state/propose (dict :type "put" :text (:text input))) (:text input)))call/method is the only admission path. The host checks the grant, the ABI
revision, and both schemas. A query cannot propose. A command proposes against
the revision it observed. The same call id with the same input returns the
recorded result and does not append again. A different input on that id is an
error. A stale revision does not overwrite.
:host-only actions reject a proposal that lacks host authority, including when
the proposal comes from a method. limbo and verdict in the tasks notebook
are sealed that way.
Session and private logs roll back with the caller. Project and global logs
refuse that rollback and survive restart as files under the kernel root’s
plugin-state/. A session log lives on the session document
(SessionRoot.pluginState), so it survives a park and a restart. Each turn
records the session’s logs when it ends, and a rewind restores the target
turn’s record. Private logs stay in memory. A caller rollback does not retract an
accepted project task. Two projects do not share a log. An explicit global
binding does. A helper keeps the conversation session id it was given.
Reducing is pure: no I/O, no self/notify, no call/method, no state/propose.
A method call has no notification destination. Writing shared state does not
wake a bot. A hook may read the method result and then notify.
6. self/notify
Section titled “6. self/notify”| Form | Effect |
|---|---|
(self/notify message) |
Origin atom. Unchanged. Does not request another round. |
(self/notify 'ambient message) |
Context for this boundary. Never continues the loop. |
(self/notify 'activating message) |
Context. Requests another round only at round end, and only with a non-empty message. |
Turn-end activation is rejected. Cancellation is not reactivated. Notes are
consumed once. Unused round-end notes expire with the turn. Two plugins share
one continuation decision: the host ORs their notes with the existing loopEnd
addenda. A worker’s note does not continue its parent. Two agents that loaded
the same module do not share notes.
7. Consent
Section titled “7. Consent”Every guard is hashed from its declaration id, point, matchers, capability,
contract, and handler source. The host stores approvals. Unchanged hashes do
not prompt. Every new or changed hash prompts, including a signed package and
including a source-only change. An unapproved guard is a load error.
pluginConsentWrite does not exist: a plugin cannot record its own approval.
The constant is GUARD_REVIEW_POLICY: prompt-on-any-hash-change for
unverified, private, and signed packages, and silent for an unchanged hash.
8. Host machinery, not a plugin surface
Section titled “8. Host machinery, not a plugin surface”Schema repair, read caching, the effect floor, and the model catalog are host
code. Plugins do not register producers, and there is no react form.
/goal and /plan are pack command files
(plugins/inhuman-plugin-goal/tools/inhuman/commands/goal.scm,
plugins/inhuman-plugin-fusion/tools/inhuman/commands/plan.scm). The client
evaluates them like the kernel defaults under
packages/harness-server/src/defaults/commands/: harness/choice rows and
(harness/action 'op :params (dict …)) leaves. A command reaches its plugin’s
state through the session verb plugin/method (:plugin, :method, :scope,
JSON :args, and :field for the composer text). Every method of that state
is granted; host-only actions are not. A read is (rpc 'plugin/method …) while
the body evaluates; a failure comes back as {ok: #f, error}, so the body still
renders.