Skip to content

Behavior surface

Source: harness/docs/plugins/polish.md Status: current — source staged for documentation review.

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.


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" }]
}
}
}
---
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.

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.

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

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

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.

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.

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.