Routines

A scheduled agent runs with nobody watching, which makes it exactly the thing you least want to over-permission. A routine bounds that risk up front: you publish the schedule and a capability envelope as one Kubernetes resource, each person enrolls themselves with their own passkey, and every message leaves through a fixed template you own.

A routine is an unattended task — a daily digest, a weekly report — published as a Routine resource in the declarative catalog. The resource pins everything a run may ever touch: the cron schedule, the agent it runs as, the read tools it may call — named as tool handles drawn from any connector — and the content templates it posts through. That bound is the capability envelope — people who join can only grant capability inside it, never beyond it. Publishing is a cluster apply, so widening the envelope takes the same access as any other change to your manifests.

One definition, many enrollments

One published definition serves everyone. Each person who joins gets their own enrollment — a private instance of the routine that runs as them and keeps its history in its own conversation. There is no roster to maintain: the definition bounds the risk, and each enrollment carries one person's own consent.

A stand-up digest looks like this, abridged:

apiVersion: polychrome.dev/v1alpha1
kind: Routine
metadata:
  name: standup
spec:
  agentId: polychrome-standup-agent # the Agent each run binds to
  schedule: "30 9 * * 1-5"          # five-field cron
  timezone: America/New_York
  envelope:
    tools:
      # connector-namespaced read handle, drawn from any registered ToolService
      - standup-tools__list_my_activity
  contentTemplates:
    - name: standup_summary_v1      # the platform synthesizes its egress tool
      destination:
        channel: C0STANDUP          # pinned; opaque to the model
      slots:                        # the only arguments the model may fill
        - name: display_name
          type: string
        - name: commits
          type: int
        - name: notes
          type: text
      prose: |
        {display_name}'s standup
        Commits: {commits}
        Notes: {notes}

The controller validates the spec and owns the CronJob that originates each firing; the template's egress tool is synthesized and its rendered message delivered platform-side, so the connector it reads from never hosts a template or learns the routine exists. kubectl get routines shows Ready and the schedule; an invalid spec lands in a Degraded phase with the reason in status.message, and the controller creates nothing for it.

The template is the tool

An unattended agent that reads external content and then posts a message is the classic injection target: something it read may try to steer what it writes, and where. Routines close that path structurally. A content template is not a filter applied to a generic post tool — it is the tool. Each template resolves to one dedicated tool whose only arguments are the template's typed slots. The destination and the fixed prose belong to the definition; the model never sees a free-form post tool, so free-form egress is unreachable rather than filtered. The platform synthesizes and runs that tool and delivers its rendered message through the edge — no connector participates in the post, so a connector stays a capability-only reader that never learns routines exist.

Slots are typed. An int renders as a number, a string as a short escaped label, a text value renders escaped and truncated, and a list renders one line per item from a fixed item shape. A slot value that names a different destination is just an ordinary string rendered into prose — it moves nothing.

The grant a person signs at enrollment covers exactly the template's destination and slot schema. Change either and every existing grant is void — each person approves the new shape in a fresh ceremony. Editing the fixed prose or the schedule voids nothing, so the definition stays maintainable without re-asking anyone. The full reasoning is in the template-is-the-tool decision record.

Joining a routine

Enrollment is self-service. You start it from the operator CLI, which prints a one-time link for the person joining:

polychrome enroll standup --provider slack --user U0123ABC
# → https://<enrollment-host>/v1/enroll/<token>   (works exactly once)

Opening the link runs the approval ceremony: the person registers a passkey and signs the egress grant with it. The resulting grant covers that person alone, stays inside the envelope, and verifies before anything persists — the server builds the approvable shape from the published definition, never from the browser, so a tampered ceremony fails instead of widening access. Signing happens once, at enrollment, because everything the grant covers is pinned in the definition (see the grants-minted-at-enrollment decision record). A routine is currently enrollable when it declares exactly one content template.

Leaving is as deliberate as joining. A person revokes their own grant with the same passkey that minted it, which ends their enrollment for good. Separately, the platform can suspend a grant without the person's involvement — a fail-closed pause, not an ending — and the two acts are cryptographically distinct in the log (per the passkey-signed-grants decision record, the platform can never mint or forge a grant). A paused enrollment gets one reminder at the next firing, then stays quiet until its grants are live again.

What a firing does

At each cron tick, the routine's CronJob posts a signed firing to the trigger edge. The edge asks the control plane for the routine's live enrollments and fans out one turn per active enrollment — each in its own conversation, attributed to that person. A routine with no active enrollments posts nothing.

Every turn runs under the same capability gate as an interactive conversation. The gate verifies the person's signed grant against the routine's current destination and slot schema before it counts; a definition that drifted since signing re-gates instead of posting. A firing that finds no live grant fails closed and leaves an audit event. There is no redelivery and no park-and-resume — the next scheduled firing is the retry, so a routine misses a run rather than ever posting one twice.

Spec reference

FieldRequiredMeaning
agentIdyesThe Agent resource each enrollment's conversations bind to.
scheduleyesFive-field cron expression driving unattended runs.
timezonenoIANA time zone the schedule is interpreted in, for example America/New_York. UTC when unset.
descriptionnoWhat the routine does, for humans reading the catalog.
envelope.toolsnoThe read tools the routine may call, as connector-namespaced <connector>__<tool> handles drawn from any registered ToolService. Template tools are implied by the templates and never listed here.
contentTemplatesat least oneEach template carries a name (the tool's identifier), a pinned destination, typed slots, and operator-owned prose. The controller enforces at least one and surfaces a violation in status; enrollment currently supports exactly one.

The full schema, including slot types and the list-item shape, is in the generated CRD manifest and via kubectl explain routine.spec.