Skip to content

actions/

Me keeps this legacy host configuration outside Realm execution. Use the captured handler migration for Me Realms.

Action specifications — YAML files that define executable operations. The host’s planner picks them by their declared input / output types and runs them as GOAP actions. The stepType discriminator selects the shape; the framework’s NameOrClassTypeIdResolver resolves either a registered short name (e.g. action, goal) or a fully-qualified class name (com.example.MyCustomActionSpec) to an ActionSpec class on the classpath. Host extensions can use either path.

stepType value Shape
action Framework PromptedActionSpec — typed LLM call producing outputTypeName
<FQN> Any other ActionSpec subtype on the classpath. Use this when shipping a host-extension shape whose YAML contract isn’t yet stable enough to claim a short name.

Short name vs FQN dispatch. A short name like action is a public contract; once realm authors write it, you can’t change the spec’s shape without breaking their YAML. Reserve short names only for shapes that have stabilised. FQN dispatch lets a host iterate freely on field names, parsing, and dispatch semantics without committing to a YAML slot upstream. The flip side: an FQN that appears in published realm YAML is itself a public identifier — a host that moves or renames the class must keep the old name resolvable, or every realm that wired against it breaks.

Example host extension (the assistant’s predicate-driven PolicyActionSpec):

realm-email/actions/policy_email_unreplied.yml
stepType: com.embabel.world.policy.spec.PolicyActionSpec
name: email_unreplied
description: A thread you're a participant in has activity from someone else, ≥ 24h ago
inputTypeNames:
- email.thread
whenExpr: "$self in participants and last_sender != $self and age >= 24h"
cost: 0.01
value: 1.0

The framework’s resolver calls Class.forName(stepType) and deserializes the rest of the YAML into that class. No registerSubtypes(...) call required, no @JsonTypeName annotation on the class.

The framework’s general-purpose PromptedActionSpec. The LLM produces a typed object the planner can chain into other actions. nullable: true declares that the LLM may return null (no output) — the planner sees the missing binding and replans.

actions/triage-issue.yml
stepType: action
name: triage-issue
description: "Triage a GitHub issue"
inputTypeNames:
- GitHubIssue
outputTypeName: TriagedIssue
prompt: |
Triage issue #{{gitHubIssue.issue}} in {{gitHubIssue.owner}}/{{gitHubIssue.repo}}.
tools:
- github
cost: 0.5
value: 1.0

Fields: name, description, llm (LlmOptions, optional), inputTypeNames, outputTypeName, pre / post (extra preconditions / effects, optional), cost / value (UtilityAI economics, default 0), canRerun (default false), prompt, toolGroups / tools / references (optional), nullable (default false), export (auto-generate a chat-callable goal, default false).

LLM-judged signals — stepType: action producing AttentionVerdict

Section titled “LLM-judged signals — stepType: action producing AttentionVerdict”

LLM-judged attention rules are vanilla stepType: action PromptedActionSpecs whose output is an AttentionVerdict (a typed {reason, confidence, tier} value) and which declare nullable: true so the LLM can return null = “skip”. The host provides a generic wrap action that lifts (AttentionVerdict, Signal) to AttentionCandidate, closing the catchup chain.

realm-email/actions/triage_email_attention.yml
stepType: action
name: triage_email_attention
description: Judge whether an email thread warrants the user's attention
inputTypeNames:
- email.thread
outputTypeName: AttentionVerdict
nullable: true
prompt: |
Judge whether the thread is worth {{user}}'s attention.
Return a verdict if so; return null to skip.
Thread:
{{emailThread}}
cost: 0.5
value: 1.0

The wrap is a two-step GOAP chain per signal: the PromptedActionSpec emits an AttentionVerdict, then the host’s wrap action emits the final AttentionCandidate. The wrap is keyed off (AttentionVerdict, Signal) inputs so it composes generically with any LLM-judgment realm — realm-email, realm-slack, realm-calendar, etc. — without needing per-signal-type wrap classes.

Deterministic rules — host-extension via FQN

Section titled “Deterministic rules — host-extension via FQN”

The assistant ships an in-tree PolicyActionSpec for cheap, no-LLM rules over a single signal — fires when its predicate matches and writes an AttentionCandidate. No surface: block — how the candidate gets rendered into a notification is a downstream concern, not the producer’s call.

The YAML uses FQN dispatch (the predicate DSL is still iterating, so we don’t reserve a short stepType slot upstream yet):

actions/policy_pr_review_overdue.yml
stepType: com.embabel.world.policy.spec.PolicyActionSpec
name: pr_review_overdue
description: A review was requested from you and it's been over 48h
inputTypeNames:
- github.pr_review_request
whenExpr: "reviewer == $self and age >= 48h"
# Same top-level cost / value as PromptedActionSpec. Cheap predicate:
# low cost, standard value — the planner picks this before any LLM
# judgment on the same signal type.
cost: 0.01
value: 1.0

A boolean expression over the matched signal’s fields. Parser source: com.embabel.world.policy.PolicyExprParser. Grammar (today; expect additions as concrete rules need them):

Operators, lowest to highest precedence:

Operator Meaning
or logical OR
and logical AND
not logical NOT (prefix)
==, != equality / inequality
<, <=, >, >= numeric or duration comparison
in membership in a collection (field in collection)

Literals:

Form Type
"..." string (\ escapes the next character)
42, 3.14 number
true, false boolean
30s, 5m, 48h, 7d, 2w duration (seconds / minutes / hours / days / weeks)

Identifiers — dotted paths walk the signal’s fields. signal.subject, source.url, reviewer, last_sender. Whatever properties the inputTypeNames DomainType declares is reachable. Unresolvable paths fail the comparison (logged at debug, not a parse error) so a misspelled field surfaces as “rule never fires” rather than a load-time crash.

Special tokens:

  • $self — the user’s identity bundle (username + configured aliases — emails, github handle, etc., from assistant.policy.self.<username> in application.yml). Use in ==, !=, or as the LHS of in. reviewer == $self, $self in participants.
  • age — derived now - signal.occurredAt as a Duration. Compare against a duration literal: age >= 24h, age < 5m.

Grouping: () for explicit precedence. not (a or b).

Worked examples:

reviewer == $self and age >= 48h
$self in participants and last_sender != $self and age >= 24h
mentioned == $self and not acknowledged
source.kind == "email" and message_count > 1

Errors at parse time include source span and the unexpected token — realm authors see the column the parser tripped on.

How policies and LLM-judgment actions compose

Section titled “How policies and LLM-judgment actions compose”

For each fresh signal of type T, the host runs one AgentProcess whose goal is “an AttentionCandidate exists on the blackboard”. Every loaded action whose preconditions match T is a candidate; UtilityAI picks in value-minus-cost order. A cheap deterministic rule ((cost 0.01, value 1.0) → utility 0.99) wins over an LLM stepType: action producing AttentionVerdict ((cost 0.5, value 1.0) → utility 0.5) — so the cheap rule runs first, writes the AttentionCandidate, the goal is satisfied, and the LLM call never happens.

When the cheap policy doesn’t match, its hasRun_<name>=TRUE blocks re-picking; the LLM action still has positive utility; the planner picks it; it produces an AttentionVerdict (or null = skip); if non-null, the host’s wrap action fires; goal satisfied. If the LLM returns null and no other action can produce an AttentionCandidate, the planner terminates naturally with no candidate.

Actions are deployed to the host’s planner on world load.