Affordances
Affordances are the action layer of SLOP. They describe what can be done, where it can be done, and how.
What makes affordances different from tools
Section titled “What makes affordances different from tools”In tool-based systems (MCP, OpenAI function calling), the AI receives a flat list of available functions — disconnected from state. The AI must figure out when each tool is applicable and what to pass to it by reading documentation.
In SLOP, affordances are attached to state nodes. They appear in context — the AI sees a message node and, alongside its properties, sees that it can be replied to, archived, or forwarded. Affordances come and go as state changes: a “merge” affordance appears on a PR only when it’s mergeable.
Key differences:
| Tools (MCP, etc.) | Affordances (SLOP) | |
|---|---|---|
| Scope | Global flat list | Per-node, contextual |
| Availability | Always listed (may fail at runtime) | Only present when expected to be valid in the current projected state |
| Discovery | Read tool descriptions | See them alongside state |
| Parameters | Must be inferred from docs | Defined with JSON Schema, contextualized |
Affordance schema
Section titled “Affordance schema”{ "action": "reply", // Identifier (unique within the node) "label": "Reply", // Human-readable label (optional) "description": "Reply to this message", // For AI understanding (optional) "params": { // JSON Schema for parameters (optional) "type": "object", "properties": { "body": { "type": "string", "description": "Reply body text" }, "reply_all": { "type": "boolean", "default": false } }, "required": ["body"] }, "dangerous": false, // Requires confirmation (optional, default false) "idempotent": false, // Safe to retry (optional, default false) "estimate": "instant" // Expected duration hint (optional)}Fields
Section titled “Fields”| Field | Required | Type | Description |
|---|---|---|---|
action | yes | string | Action identifier, unique within the node |
label | no | string | Human-readable name |
description | no | string | Explains what this does (for AI) |
params | no | JSON Schema subset (see below) | Parameter schema (if the action takes input) |
dangerous | no | boolean | If true, consumer should confirm before invoking |
idempotent | no | boolean | If true, safe to call multiple times |
estimate | no | string | Duration hint: "instant", "fast" (<1s), "slow" (>1s), "async" (background) |
params schema dialect
Section titled “params schema dialect”params uses a deliberately small subset of JSON Schema — the part reference SDKs actually enforce on every invoke. Providers SHOULD stick to this subset; consumers and reference validators MUST at minimum enforce the keywords below and MAY treat other keywords as informational hints.
Enforced keywords:
| Keyword | Applies to | Behavior |
|---|---|---|
type | any | One of "object", "array", "string", "number", "integer", "boolean", "null". Mismatch rejects with invalid_params. |
properties | object | Each named property is validated against its sub-schema when present. |
required | object | Listed keys MUST be present. |
items | array | Each element is validated against the schema (array-of-schemas / tuple form is not part of the subset). |
enum | any | Value must deep-equal one of the listed members. |
Informational keywords. description, default, title, examples — these are carried through to consumers (AI models and humans reading the affordance) but do not affect validation.
Keywords outside the subset. additionalProperties, minimum/maximum, minLength/maxLength, pattern, format, oneOf/anyOf/allOf, $ref, const, etc. are not part of the SLOP subset. Reference validators silently accept values regardless of these keywords — do not rely on them for enforcement. A provider that needs tighter validation MUST re-check inside the handler before acting on the params.
This subset is what the reference validators in packages/typescript/sdk/core, packages/python/slop-ai, packages/go/slop-ai, and packages/rust/slop-ai implement. Keeping the protocol-level contract this narrow keeps cross-language SDKs interoperable and keeps affordance schemas cheap to encode into model context.
Parameterless affordances
Section titled “Parameterless affordances”Many affordances take no input — they’re contextual actions with all information already implicit:
{ "id": "msg-42", "type": "item", "properties": { "subject": "Launch plan", "unread": true }, "affordances": [ { "action": "open" }, { "action": "mark_read" }, { "action": "archive" }, { "action": "delete", "dangerous": true } ]}The AI doesn’t need to pass a message ID to “archive” — the affordance is on the node, so the target is implicit.
Dynamic affordances
Section titled “Dynamic affordances”Affordances are part of state. They change as state changes:
// Before CI passes — no merge affordance{ "id": "pr-123", "type": "github:pull-request", "properties": { "status": "checks_pending", "mergeable": false }, "affordances": [ { "action": "comment", "params": { ... } }, { "action": "close" } ]}
// After CI passes — merge becomes available{ "id": "pr-123", "type": "github:pull-request", "properties": { "status": "checks_passed", "mergeable": true }, "affordances": [ { "action": "merge", "description": "Merge this PR into main" }, { "action": "comment", "params": { ... } }, { "action": "close" } ]}The consumer doesn’t need conditional logic to know when merging is possible — the affordance’s presence is the signal.
Invoking affordances
Section titled “Invoking affordances”Affordances are invoked via the invoke message (see Messages):
{ "type": "invoke", "id": "inv-1", "path": "/prs/pr-123", "action": "merge", "params": {}}The provider:
- Validates the action exists on the target node
- Validates parameters against the affordance’s
paramsschema. If the schema is a JSON Schema and the suppliedparamsdon’t conform, the provider MUST returnresult { status: "error", error.code: "invalid_params" }without invoking the handler. Reference SDKs (@slop-ai/core,slop-aiPython, Go, Rust) ship a shared validator and invoke it automatically before dispatch; independent implementations MUST do the same so theinvalid_paramscode is reliable across the protocol. - Re-validates that the action is still allowed under the provider’s current state, session permissions, and app policy
- Executes the action
- Returns a
resultmessage - Emits state
patchmessages reflecting any state changes caused by the action
Applicability is not authorization
Section titled “Applicability is not authorization”The presence of an affordance means the action is structurally applicable in the provider’s current projected state. It is not an authorization grant, and it is not a promise that the action will still succeed by the time the consumer invokes it.
Consumers, LLMs, browser extensions, desktop daemons, service workers, and relay processes are all untrusted from the provider’s perspective. They may be stale, buggy, compromised, or influenced by prompt injection. A provider MUST therefore treat every invoke as untrusted input and MUST re-check:
- The target node and affordance still exist in the live state
- The supplied parameters are valid
- The current caller/session is allowed to perform the action
- Any resource-specific policy guards still pass
If the action is no longer valid because state changed, return conflict. If the caller is not allowed to perform it, return unauthorized.
Contextual affordances are still valuable: they reduce invalid action attempts, help the model choose correctly, and make the safe path obvious. But they are defense in depth, not the primary security boundary.
Confirmation pattern
Section titled “Confirmation pattern”When dangerous: true, the protocol itself doesn’t enforce confirmation — it’s a hint to the consumer. The consumer (AI system) should:
- Recognize the
dangerousflag - Present the action to the user for confirmation before invoking
- Only invoke after explicit approval
This keeps confirmation UX in the consumer, while execution authorization remains in the provider.
Compound actions
Section titled “Compound actions”Sometimes an action requires multiple steps (e.g., “merge and delete branch”). Rather than encoding workflows in the protocol, use sequential invocations. The provider updates state after each action, and new affordances appear for the next step.
1. AI sees "merge" affordance on PR → invokes it2. Provider merges, state updates, "delete_branch" affordance appears3. AI sees "delete_branch" → invokes it (or doesn't)This keeps each affordance atomic and lets the AI make decisions between steps.
Affordance declaration
Section titled “Affordance declaration”Affordances must be declared in the node’s descriptor to appear in the state tree. The descriptor is the source of truth for what the consumer sees.
A handler registered separately (e.g., for routing or middleware) does not automatically create an affordance in the tree. If a developer registers a handler for "delete" on a node but doesn’t include "delete" in the node’s affordances, the action may still be callable while remaining invisible to the consumer.
This can be useful for adapter plumbing, but providers MUST NOT rely on invisibility for safety. If an operation must not be invocable by an untrusted SLOP consumer, do not expose it on the SLOP transport at all, or enforce the same authorization and policy checks at runtime.
Affordance placement
Section titled “Affordance placement”Affordances should be placed on the node they operate on. This applies at every level:
| Scope | Affordance examples | Where to place |
|---|---|---|
| Item | edit, delete, toggle, archive | On the item node |
| Collection | add, clear, search, sort, export | On the collection node |
| View | refresh, change_layout | On the view node |
| App-global | navigate, compose, logout | On the root or a context child |
App-level affordances (search, navigate, compose) should be placed on the node they operate on rather than the root. For example, search belongs on the collection it searches, navigate on a navigation context node. This keeps affordances co-located with the state they affect and ensures consistent behavior across SDK implementations.
The root node carries the app’s identity (id, name, version) and may hold truly global affordances like logout, but most actions belong on their target node.
Consumer tool-name conventions
Section titled “Consumer tool-name conventions”When an AI consumer converts affordances to LLM function tools (e.g., for OpenAI, Gemini, or Claude tool-use), it needs a tool name for each affordance. The protocol does not prescribe naming, but SDKs SHOULD follow this convention:
Short names: {nodeId}__{action}
Section titled “Short names: {nodeId}__{action}”Tool names use the node ID and action only, not the full tree path. The LLM already has the full tree as context (via formatTree or equivalent) — encoding the path in the name is redundant and wastes tokens.
card_123__edit ← 14 chars (short, readable)backlog__reorder ← 16 charsSince affordance action values are unique within a node, and node IDs are unique within their parent, the combination {nodeId}__{action} is usually globally unique. When it’s not (two nodes share the same ID at different branches), prepend the parent ID:
board_1__backlog__reorder ← board-1's backlogboard_2__backlog__reorder ← board-2's backlogContinue prepending ancestors until unique.
Resolve map, not path encoding
Section titled “Resolve map, not path encoding”The affordancesToTools utility SHOULD return a resolve function (or map) alongside the tools. The consumer uses this to map a tool name back to the full { path, action } needed for the invoke message. This keeps the encoding lossless without baking the path into the name.
Tool name: card_123__editResolves to: { path: "/inbox/messages/card-123", action: "edit" }→ invoke message: { type: "invoke", path: "/inbox/messages/card-123", action: "edit" }Sanitization
Section titled “Sanitization”Node IDs and action names SHOULD be sanitized to [a-zA-Z0-9_] in tool names (replacing hyphens and other characters with underscores). This ensures compatibility with LLM providers that restrict function name characters (e.g., Gemini requires [a-zA-Z_][a-zA-Z0-9_]*, max 64 chars).
Multi-provider prefix
Section titled “Multi-provider prefix”When a consumer connects to multiple providers, tool names SHOULD be prefixed with the provider name to avoid collisions: {providerName}__{nodeId}__{action}.
Length limits and deep trees
Section titled “Length limits and deep trees”Some LLM providers impose function name length limits (e.g., Gemini: 64 characters). Short names stay well within limits for typical apps:
| Scenario | Example | Length |
|---|---|---|
| Simple node + action | card_123__edit | 14 |
| UUID node + action | 550e8400_e29b_41d4_a716_446655440000__edit | 42 |
| Multi-provider + UUID | my_app__550e8400_e29b_41d4_a716_446655440000__edit | 50 |
| Disambiguated UUID + UUID parent | 550e8400_...440001__550e8400_...440000__edit | 79 |
The last case — UUID collision requiring a UUID parent prefix — exceeds 64 chars. This is rare (requires two sibling-level nodes with identical IDs at different branches, both with long IDs), but possible in deep trees with UUID-based identifiers.
Mitigation: Consumer implementations SHOULD apply a hash-based truncation when sanitized names exceed the provider’s limit. Truncate to limit - 8 characters and append _ plus a 7-character hash of the full name. This preserves uniqueness while respecting the limit:
fn_550e8400_e29b_41d4_a716_446655440001__550e8400_e29b → exceeds 64fn_550e8400_e29b_41d4_a716_446655440001__550e84_k3m7x9w → 64 chars, uniqueProvider guidance: Prefer short, human-readable node IDs (e.g., card-123, inbox, settings) over UUIDs where possible. Short IDs produce better tool names, clearer tree output, and avoid length limit issues entirely.