Skip to content

Guide 10 — Authored Procedural Skills

Version: v7.5.0+ Status: Stable Related: Guide 09 — Consolidation & Deep Sleep

Phase 12's autonomous learning is one source of procedural skills. The authored tier is the other: hand-authored skills that domain plugins seed at startup and the consolidation janitor leaves alone.

The two tiers coexist. They surface through the same query_skills read path and the same CapabilityRouter prompt, so the LLM picks between them transparently. They are distinguished only by the source discriminator on each node's properties.

When to use authored skills

The autonomous (regex / LLM) path learns from observed behaviour. It cannot:

  • Encode policy that has never been exercised in production.
  • Express pre-flight rules whose violation has not yet caused a failure the system noticed.
  • Carry domain-specific exception handling that requires expert judgement to articulate.

Authored skills cover that gap. Typical use cases:

  • Pre-flight rules. "Before scheduling Slack delivery, call get_integration_status to confirm the channel is connected."
  • Guardrails. "Never write to the production database without a prior get_db_status() check that returns ready."
  • Domain procedures. Bootstrapping a new tenant, opening a support ticket, generating a compliance attestation.

Enforcement layers (calibration)

Read this before authoring skills with preconditions. The v7.5.1 release notes framed PRE-FLIGHT: markers as a "hard ordering constraint over required_tool_ids". That claim is technically scoped to the capability-router LLM call, not the main brain LLM. In production, the strength of enforcement depends on which layers you opt into. Adopters who shipped the v7.5.1-v7.7.4 path expecting end-to-end enforcement (a production adopter's 3-probe wire evidence) found that the brain LLM treated PRE-FLIGHT as descriptive prose and never attempted the action tool — so the v7.7.4 precondition_gate had nothing to intercept. v7.7.5 closes that gap. The architecture below is what you actually get.

Where PRE-FLIGHT actually lands

Marker site Layer Cached? What sees it
Router prompt at prompts/router.txt:19 L1 (router-LLM only) Yes Capability-router LLM when it selects tools for this turn
MEMORY_CONTEXT at hms_system_l2.txt:16 L2 (brain-LLM) No (volatile) Brain LLM as part of the per-turn memory snapshot

The router gate works because the router LLM is given a short prompt with the explicit ordering clause. The brain LLM sees the same marker as one line inside a volatile memory block — Anthropic models treat that as descriptive context, not imperative instruction.

Stratigraphic primer (L0 / L1 / L2)

Layer Source Cached Plugin position
L0 Brain kernel Bundled hms_kernel.txt (framework-owned) Yes RESERVED — position="kernel" is a no-op in v7.7.5 (see src/symfonic/core/plugins/contribution.py:20-26)
L1 OS services hms_system_l1.txt + plugin contributions Yes position="cached" lands here
L2 Per-turn task hms_system_l2.txt + MEMORY_CONTEXT No position="volatile" lands here

The framework rule that makes this matter: Anthropic models follow L0/L1 imperatives ("YOU MUST CALL X BEFORE Y") much more reliably than L2 prose retrieved by vector similarity. The pattern an adopter's _FAILURE_HANDLING directive uses (and which actually changes model behaviour for them) is L1 position="cached" with named-section imperative phrasing.

Three enforcement options

Option Knob What it does When to use
(a) Router gate Automatic (v7.5.1) Router LLM honours PRE-FLIGHT ordering when selecting tools Always-on when enable_hms_prompt=True and the router LLM is wired
(b) Brain precondition_gate procedural_enforce_preconditions=True (v7.7.4) LangGraph topology adds a third sibling node that intercepts attempted action tool_use and emits a synthetic ToolMessage(status="error") for unmet bare-id preconditions Catches non-compliance after the model attempts the action; does NOT induce attempts that never happened
(c) L1 imperative render procedural_render_preflight_in_l1=True (v7.7.5) Framework synthesises a ## ENFORCED PRE-FLIGHT RULES block from active skills' bare-id preconditions and injects it at L1 position="cached" as imperative phrasing Lifts compliance: the brain LLM sees the rule as a directive and is dramatically more likely to attempt the precondition tool
(e) Governance objector (kernel) governance(preconditions=[...]) The precondition is a governance rule at the pre-tool rung. An unmet one raises GovernanceRefused before the tool executes — the tool's own counter stays at zero — and the refusal names the rule in rule_ids The kernel path. Enforcement, not persuasion: unlike (a)-(c) it does not depend on what the model chose to do
(d) Manual escape hatch None — adopter-owned BaseDomainPlugin.inject_contributions(position="cached") Adopter writes their own L1 directive at the plugin layer (e.g., an adopter's _FAILURE_HANDLING directive at the plugin layer) When you need failure-handling or domain-specific policy that isn't tied to a procedural skill

Recommended pairing on the legacy path: (b) + (c). The L1 render lifts the model's likelihood of attempting the precondition; the gate catches the residual cases. Shipping (c) alone leaves the failure mode open if the model still skips the precondition. Shipping (b) alone leaves it open if the model never attempts the action.

On the kernel path, use (e), and keep rendering the rule. The two answer different questions and both are worth having. Rendering asks the model to comply; the objector does not ask. Render the skill as untrusted content — an operator wrote it into a store, so it is proposed text, and text a store can be made to hold is text an attacker can propose:

Agent(
    provider,
    tools=[...],
    capabilities=[
        PromptingCapability(sources=[StaticSource(text=rendered, untrusted=True)]),
        governance(preconditions=[MyPreconditionObjector(...)]),
    ],
)

See examples/authored_skills/ for a runnable end-to-end reference: it runs four turns and prints the tool's own execution counter for each.

The API: ProceduralLayer.seed_authored_skill

from symfonic.memory import ProceduralLayer
from symfonic.memory.models.entry import MemoryEntry
from symfonic.memory.types import MemoryLayer, TenantScope

scope = TenantScope(tenant_id="acme")

skill = MemoryEntry(
    layer=MemoryLayer.PROCEDURAL,
    tenant_id=scope.tenant_id,
    content=(
        "Before scheduling any delivery, confirm the integrations the "
        "delivery channel relies on are connected for the active brand."
    ),
    importance=7.0,
    metadata={
        "label": "Pre-flight: integration status check",
        "steps": ["call get_integration_status(brand_id=<active>)"],
        "context": "delivery scheduling pre-flight",
    },
)

node = await procedural_layer.seed_authored_skill(
    scope,
    skill,
    source_tag="authored:my-plugin",
    precondition="before delivery-config, call get_integration_status",
)

seed_authored_skill is a thin convenience wrapper around store_skill(... status="approved") that:

  1. Validates source_tag starts with "authored:" — anything else raises ValueError so the janitor guard is not silently bypassed on a typo.
  2. Folds the source tag and optional precondition into node.properties (properties["source"], properties["precondition"]).
  3. Lands the result at status="approved" (immediately active in query_skills).

The convention is source_tag="authored:<plugin-name>". Multiple domain plugins can coexist by picking distinct plugin names.

Direct construction (when you cannot use the wrapper)

If you need to set fields the wrapper does not expose, use store_skill directly and set the source tag in metadata yourself:

skill.metadata["source"] = "authored:my-plugin"
await procedural_layer.store_skill(scope, skill, status="approved")

The janitor's authored-tier guard recognises any node whose properties["source"] starts with authored:, regardless of which write path produced it.

The janitor guard

SemanticMerge is the deduplication janitor that runs inside every store_skill write and during the bulk POST /procedures/deduplicate endpoint. Pre-v7.5 it could absorb a hand-authored canonical into an auto-promoted draft (or vice versa) if the labels or content drifted close enough.

v7.5.0 adds an explicit guard:

  • SemanticMerge.find_duplicates returns [] when the incoming node carries an authored: source tag (authored writes always produce fresh rows, never merge into pre-existing entries).
  • SemanticMerge.find_duplicates skips authored entries in the existing-nodes scan (auto-promoted drafts cannot absorb a hand-edited canonical).
  • SemanticMerge.deduplicate_all skips authored primaries (bulk dedup never mutates the authored tier).

The net effect: authored skills are immutable to the janitor. Their labels, steps, content, and precondition are preserved exactly as the seeding code wrote them.

Optional pre-flight precondition

The precondition field captures one or more pre-flight rules. The common shape is a single one-line natural-language rule; v7.6.2 widened the field to also accept an ordered list of rules so a single skill can encode multi-step pre-flights. When present, the field:

  • Lands on the procedural node at properties["precondition"].
  • Surfaces through query_skills in entry.metadata["precondition"] (same shape that was stored — consumers should isinstance-check both str and list).
  • Is rendered into the CapabilityRouter prompt as one indented PRE-FLIGHT: marker per rule, and the prompt instructs the LLM that EVERY named pre-flight tool MUST appear in required_tool_ids BEFORE the action tool, in the order the markers appear. The rules are hard ordering constraints at the router LLM; brain-LLM enforcement requires one of the three opt-ins documented in Enforcement layers (calibration) above.

Single rule (v7.5+ shape)

await procedural_layer.seed_authored_skill(
    scope,
    skill,
    source_tag="authored:my-plugin",
    precondition="before delivery-config, call get_integration_status",
)

Renders as:

## 4. RELEVANT SKILLS FROM MEMORY
- Before scheduling any delivery... (steps: ['call get_integration_status(...)'])
  PRE-FLIGHT: before delivery-config, call get_integration_status

Multi-rule (v7.6.2+ shape)

await procedural_layer.seed_authored_skill(
    scope,
    skill,
    source_tag="authored:my-plugin",
    precondition=[
        "call get_integration_status(brand_id=<active>)",
        "call get_brand_quota(brand_id=<active>)",
    ],
)

Renders as:

## 4. RELEVANT SKILLS FROM MEMORY
- Before scheduling weekly delivery... (steps: ['call create_scheduled_task(...)'])
  PRE-FLIGHT: call get_integration_status(brand_id=<active>)
  PRE-FLIGHT: call get_brand_quota(brand_id=<active>)

The LLM is instructed to include every pre-flight tool in required_tool_ids in the order shown, before the action tool. A single-element list is collapsed back to a string at storage time so the prompt-cache stays warm for callers who happen to pass 1-element lists. An all-empty list is treated as no precondition.

Scoped rules: state predicates

A precondition entry may be a state predicate instead of a rule string — a way of saying this rule is only about some accounts:

await procedural_layer.seed_authored_skill(
    scope,
    skill,
    source_tag="authored:my-plugin",
    precondition=[
        "get_integration_status",
        {"state": {"path": "user_profile.region", "op": "eq", "value": "US"}},
    ],
)

Ask whether a stored rule applies to the state in front of you with symfonic.memory.skill_applies:

from symfonic.memory import skill_applies

precondition = entry.metadata["precondition"]

if skill_applies({"user_profile": {"region": "EU"}}, precondition):
    ...  # not reached: this rule is about US accounts

Where the state comes from

A turn declares what the deployment knows about it, and governance hands that to every rule as subject.properties:

await agent.run(
    "Announce that we ship Friday.",
    state={"user_profile": {"region": "US"}, "verified": False},
)


class PreconditionObjector:
    name = "authored_skill_preflight"
    tool_name = "publish_announcement"

    def __init__(self, precondition):
        self._precondition = precondition

    def check(self, call, subject):
        # Does this rule apply here? Only then, is it satisfied?
        if not skill_applies(subject.properties, self._precondition):
            return None
        if subject.properties.get("verified"):
            return None
        return "get_integration_status must be called first"

Four properties of that snapshot, and each exists to close a way it could otherwise become a back channel:

  • Per turn, not per agent. Two turns on one compiled agent can declare different states without rebuilding a single rule — and a rule cannot carry a fact from one turn into the next.
  • Frozen, recursively. One rule cannot rewrite a fact the next rule reads. A stage says what it decided by returning a verdict.
  • Copied at the boundary. A caller who keeps the dict they passed cannot change a turn already under way.
  • Never printed. The field is excluded from the request's repr, so an account region does not travel into a log line or a traceback frame.

Passing something that is not a mapping raises at the call site rather than reading as empty three rungs later — a rule that silently sees no properties is a rule that silently stops applying.

Name Answers
skill_applies(state, precondition) do all of a skill's predicates hold?
state_predicates_in(precondition) the predicates in a list, unwrapped from {"state": ...}
evaluate_state_predicate(state, predicate) one predicate → PredicateOutcome(matched, reason)

Ask "does this apply?" before "is it satisfied?" Reversing the two refuses an EU account for failing a rule written about US accounts. examples/authored_skills/ shows both orders' consequences as separate turns.

Operators: eq, neq, in, exists. Paths are dotted and resolve UPPER-then-lower per segment, because prompt-render state arrives upper-snake and runtime state arrives snake_case — a rule written against one keeps matching under the other.

Evaluation fails closed. A missing path, an unknown operator, a missing value, or an in whose value is a bare string (which would silently substring-match, so region in "USA" would admit "US") all evaluate to "does not match". A rule nobody can evaluate does not admit, because the alternative is a filter that widens whenever someone mistypes it. PredicateOutcome.reason says which of these happened.

These functions were private until 2026-09-01. The only evaluator lived inside the legacy precondition_gate LangGraph node, so a rule of this shape could be stored through the public contract and answered by nothing public. That node now calls the same functions — one interpreter of the DSL rather than two that drift apart.

Constraints (HTTP)

POST /memories/procedural enforces:

  • Single string: len <= 500 characters.
  • List: len(list) <= 5 entries, each entry a string with len <= 500 characters.

Skills without a precondition render byte-identical to the pre-7.5.1 bullet shape, so prompt-cache parity holds for adopters who never use the authored tier.

HTTP API surface

POST /memories/procedural accepts the same shape via the new optional precondition and source fields on ProceduralCreateRequest:

{
  "label": "Pre-flight: integration status check",
  "content": "Before scheduling any delivery...",
  "steps": ["call get_integration_status(brand_id=<active>)"],
  "context": "delivery scheduling pre-flight",
  "status": "approved",
  "precondition": "before delivery-config, call get_integration_status",
  "source": "authored:my-plugin"
}

precondition accepts the multi-rule shape too (v7.6.2+):

{
  "label": "Weekly scheduled delivery",
  "content": "Schedule the weekly Slack/email delivery...",
  "steps": ["call create_scheduled_task(...)"],
  "status": "approved",
  "precondition": [
    "call get_integration_status(brand_id=<active>)",
    "call get_brand_quota(brand_id=<active>)"
  ],
  "source": "authored:my-plugin"
}

The Pydantic validator enforces len(list) <= 5 and len(entry) <= 500 per entry; violations surface as HTTP 422 (Unprocessable Entity).

Same constraint as the in-process API: source values starting with authored: opt into the janitor guard. Other source values are stored verbatim with no special treatment.

Coexistence with Phase 12

Authored skills and auto-promoted drafts live in the same procedural store and surface through the same query_skills projection. To distinguish them, read entry.metadata["source"]:

Source Origin
authored:<plugin> seed_authored_skill or the HTTP API
phase_12_promotion Regex extractor (v6.1+)
phase_12_llm_extractor LLM extractor (v7.6+)

Authored skills are immune to dedup. Both Phase 12 sources still run through SemanticMerge so repeated runs do not flood the queue.

See also