Skip to content

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)
ScopeGlobal flat listPer-node, contextual
AvailabilityAlways listed (may fail at runtime)Only present when expected to be valid in the current projected state
DiscoveryRead tool descriptionsSee them alongside state
ParametersMust be inferred from docsDefined with JSON Schema, contextualized
{
"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)
}
FieldRequiredTypeDescription
actionyesstringAction identifier, unique within the node
labelnostringHuman-readable name
descriptionnostringExplains what this does (for AI)
paramsnoJSON Schema subset (see below)Parameter schema (if the action takes input)
dangerousnobooleanIf true, consumer should confirm before invoking
idempotentnobooleanIf true, safe to call multiple times
estimatenostringDuration hint: "instant", "fast" (<1s), "slow" (>1s), "async" (background)

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:

KeywordApplies toBehavior
typeanyOne of "object", "array", "string", "number", "integer", "boolean", "null". Mismatch rejects with invalid_params.
propertiesobjectEach named property is validated against its sub-schema when present.
requiredobjectListed keys MUST be present.
itemsarrayEach element is validated against the schema (array-of-schemas / tuple form is not part of the subset).
enumanyValue 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.

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.

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.

Affordances are invoked via the invoke message (see Messages):

{
"type": "invoke",
"id": "inv-1",
"path": "/prs/pr-123",
"action": "merge",
"params": {}
}

The provider:

  1. Validates the action exists on the target node
  2. Validates parameters against the affordance’s params schema. If the schema is a JSON Schema and the supplied params don’t conform, the provider MUST return result { status: "error", error.code: "invalid_params" } without invoking the handler. Reference SDKs (@slop-ai/core, slop-ai Python, Go, Rust) ship a shared validator and invoke it automatically before dispatch; independent implementations MUST do the same so the invalid_params code is reliable across the protocol.
  3. Re-validates that the action is still allowed under the provider’s current state, session permissions, and app policy
  4. Executes the action
  5. Returns a result message
  6. Emits state patch messages reflecting any state changes caused by the action

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:

  1. The target node and affordance still exist in the live state
  2. The supplied parameters are valid
  3. The current caller/session is allowed to perform the action
  4. 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.

When dangerous: true, the protocol itself doesn’t enforce confirmation — it’s a hint to the consumer. The consumer (AI system) should:

  1. Recognize the dangerous flag
  2. Present the action to the user for confirmation before invoking
  3. Only invoke after explicit approval

This keeps confirmation UX in the consumer, while execution authorization remains in the provider.

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 it
2. Provider merges, state updates, "delete_branch" affordance appears
3. AI sees "delete_branch" → invokes it (or doesn't)

This keeps each affordance atomic and lets the AI make decisions between steps.

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.

Affordances should be placed on the node they operate on. This applies at every level:

ScopeAffordance examplesWhere to place
Itemedit, delete, toggle, archiveOn the item node
Collectionadd, clear, search, sort, exportOn the collection node
Viewrefresh, change_layoutOn the view node
App-globalnavigate, compose, logoutOn 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.

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:

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 chars

Since 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 backlog
board_2__backlog__reorder ← board-2's backlog

Continue prepending ancestors until unique.

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__edit
Resolves to: { path: "/inbox/messages/card-123", action: "edit" }
→ invoke message: { type: "invoke", path: "/inbox/messages/card-123", action: "edit" }

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

When a consumer connects to multiple providers, tool names SHOULD be prefixed with the provider name to avoid collisions: {providerName}__{nodeId}__{action}.

Some LLM providers impose function name length limits (e.g., Gemini: 64 characters). Short names stay well within limits for typical apps:

ScenarioExampleLength
Simple node + actioncard_123__edit14
UUID node + action550e8400_e29b_41d4_a716_446655440000__edit42
Multi-provider + UUIDmy_app__550e8400_e29b_41d4_a716_446655440000__edit50
Disambiguated UUID + UUID parent550e8400_...440001__550e8400_...440000__edit79

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 64
fn_550e8400_e29b_41d4_a716_446655440001__550e84_k3m7x9w → 64 chars, unique

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