Who may do what

Copy Markdown
Mix.install([{:wardrik, "~> 0.1.0-rc.1"}, {:kino, "~> 0.19"}])

What you'll build

By the end, Maria owns an agent, Jon can read it but not stop it, and Sam can't even see it. Then you make an API token, use it, and revoke it. Nothing here calls a model, so you need no key.

Setup

Start Wardrik, act as Maria, and define her agent:

{:ok, _} = Wardrik.start_link(name: MyApp.AgentRuntime)

scope = Wardrik.as(MyApp.AgentRuntime, "acme/@maria", roles: [:owner], resources: ["acme/@maria/*"])

{:ok, _agent} =
  Wardrik.Agent.define(scope, "acme/@maria/books",
    model: "anthropic:claude-haiku-4-5",
    instructions: "You keep Maria's books."
  )

A scope is three things: Wardrik, who acts, and what they're granted. Every operation takes one. Maria is :owner of everything under acme/@maria/.

Two more people

Jon may read and message Maria's agents. Sam has no grants at all:

jon =
  Wardrik.as(MyApp.AgentRuntime, "acme/@jon",
    roles: [:reader, :messenger],
    resources: ["acme/@maria/agent/*"]
  )

sam = Wardrik.as(MyApp.AgentRuntime, "acme/@sam")

A resource names what's acted on. The agent acme/@maria/books is the resource acme/@maria/agent/books.

Try it

Each person tries the same four things on Maria's agent:

people = [maria: scope, jon: jon, sam: sam]

tries = [
  read: &Wardrik.Agent.read(&1, "acme/@maria/books"),
  events: &Wardrik.Agent.events(&1, "acme/@maria/books"),
  stop: &Wardrik.Agent.stop(&1, "acme/@maria/books"),
  define: &Wardrik.Agent.define(&1, "acme/@maria/books", model: "anthropic:claude-haiku-4-5")
]

answer = fn
  {:error, %Wardrik.Error{type: type}} -> Atom.to_string(type)
  _ok -> "ok"
end

may = fn people ->
  for {name, person} <- people do
    Map.new([{:person, name} | for({action, try} <- tries, do: {action, answer.(try.(person))})])
  end
  |> Kino.DataTable.new(keys: [:person | Keyword.keys(tries)], name: "Who may do what")
end

may.(people)
  • Maria may do all of it.
  • Jon may read, but stop and define are forbidden. A statement reaches the agent, without the action.
  • Sam gets not_found for everything. No statement reaches the agent, so for him it isn't there.

The two errors read differently:

{Wardrik.Agent.stop(jon, "acme/@maria/books"), Wardrik.Agent.read(sam, "acme/@maria/books")}

not_found keeps Sam from learning the agent exists.

Grant one more thing

Wardrik.grant/2 adds a statement to a scope. Let Jon define this one agent:

jon = Wardrik.grant(jon, roles: [:definer], resources: ["acme/@maria/agent/books"])

may.(maria: scope, jon: jon, sam: sam)

Jon may now stop and define books. His grant names one agent, so Maria's other agents stay as they were.

RoleActions
:readeragent.read, agent.events, connection.read, mcp_server.read, ceiling.read
:messengeragent.ask, agent.cancel, agent.clear
:approveragent.decide
:defineragent.define, agent.spawn, agent.stop, agent.forget, connection.connect, mcp_server.add
:workeragent.serve
:ownerevery action

API tokens

Programs reach Wardrik over HTTP with a token. Making tokens needs deployment.tokens on deployment:

admin = Wardrik.grant(scope, actions: ["deployment.tokens"], resources: ["deployment"])

{:ok, issued} =
  Wardrik.Tokens.create(admin,
    name: "ci",
    roles: ["reader", "messenger"],
    resources: ["acme/@maria/agent/*"],
    expires_in: 3600
  )

issued.secret

The secret is returned once, now. Wardrik keeps only its SHA-256.

The token acts for Maria. Turn its secret back into a scope, and use it:

{:ok, ci} = Wardrik.authenticate(MyApp.AgentRuntime, issued.secret)

{ci.principal.id, answer.(Wardrik.Agent.read(ci, "acme/@maria/books")),
 answer.(Wardrik.Agent.stop(ci, "acme/@maria/books"))}

It may read, as reader allows. It may not stop the agent.

Make tokens from a form

A token can't do more than whoever makes it. Pick a role and the resources it reaches. The table below lists every token:

tokens = Kino.Frame.new()

show_tokens = fn ->
  {:ok, list} = Wardrik.Tokens.list(admin)

  rows =
    for token <- list do
      %{
        id: token.id,
        name: token.name,
        for: token.principal,
        actions: Enum.flat_map(token.statements, & &1.actions) |> Enum.join(", "),
        resources: Enum.flat_map(token.statements, & &1.resources) |> Enum.uniq() |> Enum.join(", "),
        expires: token.expires
      }
    end

  Kino.Frame.render(
    tokens,
    Kino.DataTable.new(rows, keys: [:id, :name, :for, :actions, :resources, :expires], name: "Tokens")
  )
end

form =
  Kino.Control.form(
    [
      name: Kino.Input.text("Name", default: "deploy-bot"),
      role: Kino.Input.select("Role", [{"definer", "definer"}, {"reader", "reader"}, {"owner", "owner"}]),
      resources: Kino.Input.text("Resources", default: "acme/@maria/agent/*")
    ],
    submit: "Make token"
  )

result = Kino.Frame.new()

make_token = fn %{data: %{name: name, role: role, resources: resources}} ->
  case Wardrik.Tokens.create(admin, name: name, roles: [role], resources: [resources]) do
    {:ok, issued} ->
      Kino.Frame.render(result, Kino.Markdown.new("Made **#{name}**. Its secret: `#{issued.secret}`"))

    {:error, error} ->
      Kino.Frame.render(result, Kino.Markdown.new("**#{error.type}**: #{error.message}"))
  end

  show_tokens.()
end

Kino.listen(form, make_token)
show_tokens.()

Kino.Layout.grid([form, result, tokens], columns: 1)

Try acme/agent/* as the resources. Maria owns acme/@maria/* alone, so that token is forbidden: it would reach agents she can't.

Revoke a token

A revoked token is refused from then on:

{:ok, _token} = Wardrik.Tokens.revoke(admin, issued.token)
show_tokens.()

Wardrik.authenticate(MyApp.AgentRuntime, issued.secret)

The ci row leaves the table. Agents a token defined keep running, under the policy they were defined with.

Where next

  • Who may do what: every resource, token options, and writing your own authorizer.
  • Orgs: agents and settings an org shares.
  • Events notebook: the audit records each token made and revoked.
  • Tools notebook: give agents tools, and decide which calls wait for a person.