Skip to content

NEXT · MULTI-STEP PLANS AND SCHEDULES

Write multi-step plans, and put them on a schedule

You have an agent with a plan. Now wire several gate operations into one plan: read a database, ask a model, write a file, post a message, with every step authorized before it acts and signed after. Then give the plan a schedule so it runs without you.

New here? Start with Build a governed AI application instead; this page assumes you finished it.

A plan is where governance pays off across many steps: read a database, ask a model, write a file, post a message, and every step is gated the same way. You wire the steps, EKKA governs each one.

What you'll do

Set up the CLI, run ekka demo as a zero-authoring governed run, then author your own plan with ekka plan, grant it what it needs, run it, and verify the receipts. The by-hand quickstart teaches the deny, grant, allow, receipt loop this guide builds on, one command at a time.


Before you start

You do not need data or API keys of your own to follow this guide: the demo uses a hosted database and a hosted AI model. The installer runs on macOS and Linux, and on Windows inside WSL.


Setup (one time)

You run these three steps once per machine. They are the same as the by-hand quickstart, so skip ahead if you have already done them.

1 · Install the ekka CLI

curl -fsSL https://get.ekka.ai/install.sh | sh
ekka --version

This installs a single signed binary, ekka, onto your PATH. That one binary is both the CLI you type and the Enclave it starts in step 3. Re-running the install line upgrades in place.

2 · Sign in and create your organization

ekka login --email [email protected]
ekka org create jane-labs

Signing in emails you a link, so there is no password to choose; a new address creates the account. org create takes an organization code of your own (lowercase letters, digits and hyphens), creates the organization, and makes it the one your commands act in. Your local config, including EKKA's pinned key, lands in ~/.ekka/. Already invited to somebody else's organization? Skip org create: you joined it when you signed in.

3 · Start your Enclave

ekka enclave start

Wait for authenticated; entering run loop, then leave it running. The Enclave is the worker that carries out each governed step on your side, so your data and keys never leave your machine. Only short, signed summaries travel back to EKKA.

Confirm what you are signed in as:

ekka whoami

This prints your organization, your environment, and your Enclave identity.


Your first governed run, with zero authoring

Before you author anything, watch a real governed plan run end to end:

ekka demo

This runs a real governed plan (a Postgres read plus an AI-model call) through the full loop: allow, deny, grant, revoke, every outcome signed. Then verify the audit chain yourself, offline:

ekka receipts verify

It answers with how many records checked out and that nothing has been changed, added or removed since the first one.

What just happened

You ran a governed plan without authoring a single line, and then proved it was governed by re-checking the hash chain and every signature offline, back to the GENESIS entry, without trusting any dashboard.


Author and run your own plan

A plan is a small file that wires gate operations into ordered steps. The file is the whole plan: a plan block at the top carries its name, and a definition block carries the steps. You create it from the file alone, and then run the versioned identity it registers.

(You do not have to start from a blank file: ekka plan template <file> --gate <type> --op <op> writes a complete one-step starter you can grow. Here we write one by hand to show the shape.)

1 · Create the plan

A minimal plan: a File gate round-trip that writes a note and then reads it back. The File gate is built into your Enclave, so this plan needs no external gate connected. The shape is two steps in one operation:

{
  "plan": { "code": "myapp.filenote", "name": "File note" },
  "definition": {
    "schema_version": "ekka.plan.v2",
    "operations": [
      {
        "id": "fileRoundTrip",
        "steps": [
          {
            "id": "writeNote",
            "action_ref": "ekka.local.file.write.v1",
            "executor_type": "function",
            "feature": "file.fs.write",
            "inputs": {
              "resource": "quickstart-note.txt",
              "content": "Hello from my first governed plan."
            }
          },
          {
            "id": "readNote",
            "action_ref": "ekka.local.file.read.v1",
            "executor_type": "function",
            "feature": "file.fs.read",
            "inputs": { "resource": "quickstart-note.txt" }
          }
        ]
      }
    ]
  }
}

Every step also carries a step_input_contract and a step_output_contract describing its shape, and the operation carries an execution block. Save the whole file, contracts included, as plan.json:

The full plan
{
  "plan": {
    "code": "myapp.filenote",
    "name": "File note"
  },
  "definition": {
    "schema_version": "ekka.plan.v2",
    "run_context": [],
    "inputs": {},
    "operations": [
      {
        "id": "fileRoundTrip",
        "display_name": "Write a note, then read it back",
        "execution": {
          "preferred": {
            "mode": "async",
            "runtime": "node"
          },
          "allowed": []
        },
        "steps": [
          {
            "id": "writeNote",
            "action_ref": "ekka.local.file.write.v1",
            "executor_type": "function",
            "feature": "file.fs.write",
            "identity": {
              "requires_user_context": false
            },
            "inputs": {
              "resource": "quickstart-note.txt",
              "content": "Hello from my first governed plan."
            },
            "action_input": {},
            "step_input_contract": {
              "type": "object",
              "required": [
                "resource",
                "content"
              ],
              "properties": {
                "resource": {
                  "type": "string"
                },
                "content": {
                  "type": "string"
                }
              }
            },
            "step_output_contract": {
              "type": "object",
              "required": [],
              "properties": {
                "op": {
                  "type": "string"
                },
                "resource": {
                  "type": "string"
                },
                "bytes": {
                  "type": "number"
                },
                "sha256_b64": {
                  "type": "string"
                }
              }
            }
          },
          {
            "id": "readNote",
            "action_ref": "ekka.local.file.read.v1",
            "executor_type": "function",
            "feature": "file.fs.read",
            "identity": {
              "requires_user_context": false
            },
            "inputs": {
              "resource": "quickstart-note.txt"
            },
            "action_input": {},
            "step_input_contract": {
              "type": "object",
              "required": [
                "resource"
              ],
              "properties": {
                "resource": {
                  "type": "string"
                }
              }
            },
            "step_output_contract": {
              "type": "object",
              "required": [],
              "properties": {
                "op": {
                  "type": "string"
                },
                "resource": {
                  "type": "string"
                },
                "bytes": {
                  "type": "number"
                },
                "sha256_b64": {
                  "type": "string"
                },
                "content": {
                  "type": "string"
                }
              }
            }
          }
        ]
      }
    ]
  }
}

Each step names an action_ref (which gate operation to run), its inputs, and an input/output contract describing its shape. content is the file body, as a string. A read returns content too, which is what lets a later step use what an earlier one wrote: bind it as @<operation>.<step>.output.content. To use a different gate, swap the step: a Postgres read is ekka.gate.postgres.v1, an AI call is an llm step, a Slack post is ekka.gate.slack.v1 with resource: channels/<name>.

The plan block is the plan's registration: code is the part after ekka. in its identity, and name is what ekka plan list shows. (It can also carry an optional version, status, or agent; left out, EKKA defaults them. The plan runs as your organization's core agent.)

Check the file first, then point ekka plan create at it. validate runs every check create would (the plan schema, each step's contracts, whether the name is already taken) while creating nothing, so it is also the command your CI can run on plan files:

ekka plan validate plan.json
ekka plan create plan.json

ekka plan create prints the plan's full identity, which looks like:

Copy that identity exactly. You will need all of it to run the plan.

2 · Grant the plan what it needs

The File gate plan above needs no Grant: the File gate runs inside your Enclave, which owns the folder it may touch, so you can skip straight to step 3 and run it now. Grants come in as soon as your plan calls an external gate.

A plan is governed like everything else on EKKA: default-deny. Each external gate operation needs a standing Grant first. Add one Grant per operation:

# Let the plan read a Postgres resource
ekka gate grant add --type postgres --instance <alias> --resource customers \
  --capability knowledge.postgres.read

# Let the plan call the AI model
ekka gate grant add --type llm --instance <alias> --resource anthropic/sonnet-4 \
  --capability llm.infer

You do not have to guess every Grant up front. If a run is denied, the error prints the exact ekka gate grant add command that would authorize the step. Run that, then run the plan again.

3 · Run the plan

Run the full identity that plan create printed:

ekka plan run "[email protected]"

If your plan declares inputs, pass them with repeated --input flags, for example --input question="what is the time in London?". (The File example above takes none.)

4 · Verify the receipts

ekka receipts verify

Every step that ran produced a signed Receipt, chained so tampering is obvious. receipts verify re-checks the whole chain offline.


Run your plan on a schedule

You do not have to be at the keyboard for a plan to run. A schedule runs a plan on a cadence, for your organization only.

Schedule the plan you created above:

# Every weekday at 9am, New York time
ekka plan schedule "[email protected]" \
    --cron "0 9 * * 1-5" --tz America/New_York

The cadence is a standard 5-field cron expression, an alias (@hourly, @daily, @weekly, @monthly), or a friendly interval:

ekka plan schedule "[email protected]" --every 1h

--every accepts 1m, 5m, 15m, 30m, 1h, 6h, 12h, and 1d. Timezones are IANA names (like America/New_York); the default is UTC. If the plan declares inputs, attach them with the same repeatable --input key=value flag you use on ekka plan run, and every fire carries them.

You can also create and schedule in one command:

ekka plan create plan.json --schedule "@daily"

Every scheduled fire is a normal governed run. Grants are enforced the same way, every step produces a signed Receipt, and ekka receipts verify covers scheduled runs exactly like the ones you start by hand. There is no separate, less-governed path for timers.

Check what is scheduled and whether it is firing:

ekka plan schedules

The listing shows each schedule's cron, its active state, its NEXT-RUN, and its LAST-RUN. A populated LAST-RUN means the schedule is firing.

Pause, resume, or remove a schedule by the id the listing shows:

ekka plan unschedule <schedule-id> --pause     # keep it, stop it firing
ekka plan unschedule <schedule-id> --resume    # start again at the next real slot
ekka plan unschedule <schedule-id>             # remove it

A paused schedule never back-fires the runs it missed: resuming realigns it to the next real slot. Downtime works the same way, so missed fires are skipped, not bursted, and an hourly plan will not fire five times to catch up.

Two requirements: scheduling needs an admin role in your organization, and the plan must be active.


Rules the plan surface enforces

ekka plan run takes the full identity, not the bare code. Run the whole thing plan create printed, for example [email protected], not just the myapp.summary part of it.

Plan inputs are supplied on the run command. Pass each one as --input key=value, repeating the flag. A missing input is a common cause of a step failing.

A Slack channel is addressed as channels/<name>. The resource grammar allows no hyphens in the first path segment, and channel names often contain hyphens, so the channels/ prefix carries the name:

ekka gate grant add --type slack --instance <alias> \
    --resource channels/team-alerts --capability messaging.slack.send

executor_type comes from a fixed list: llm, data_call, function, diagnostic, or control_plane. File gate steps are not a separate type. Declare them as "executor_type": "function" and let the action_ref (ekka.local.file.read.v1, ekka.local.file.write.v1) route the step to the File gate, exactly as the example above does. A type outside that list is rejected.

The plan file is strict JSON, and unknown keys are rejected. A plan file has exactly two top-level keys: plan (the registration: code, name, and optionally version, status, agent) and definition (the steps). Inside definition the keys are fixed too: schema_version, run_context, operations, and optionally resources, inputs, variables, output_mapping, and chat_output_mode. The same strictness applies inside operations and steps, so keep authoring notes outside the plan file: JSON has no comment syntax, and an extra "comment" key is rejected.


Make it reusable

The second time you write those steps, stop and publish them instead.

A skill is a body of work published under a name. Take the plan you just wrote and promote one of its operations:

ekka skill promote my-plan.json --name report.daily --out report.daily.skill.json

Every @input.<name> in those steps becomes a port the caller fills in. Read the file, then check and publish it:

ekka skill test report.daily.skill.json
ekka skill publish report.daily.skill.json

Now any plan calls it in one step:

{
  "id": "report",
  "skill": "report.daily@1",
  "inputs": { "channel": "@input.channel" }
}

Name the version. A published version is never rewritten, so @1 runs the same work next year.

Two things to check in the generated file before you publish, and promote tells you about both:

  • output_map is blank. Only you can say which step's output the caller receives, and under what name.
  • A pinned gate. Your plan named a concrete gate because it runs on your machine. A skill is for whoever has one, so make it a port.

Skills has the whole picture, including how to generate one straight from a connected API and who is allowed to read the steps.

What you just built

You wired multiple governed steps into one plan, authorized each with a Grant, ran it under your own Enclave, and verified a signed, offline-checkable receipt chain for the whole thing. The same grammar (deny, grant, allow, receipt) governs every gate a step touches: a database, an AI model, a file, a message.

Next steps