Skip to content

Capabilities and Effects

Skein uses a capability system to control side effects. Any function that performs I/O — HTTP requests, database access, file operations — must be authorized by a capability declaration at the module level. This is checked at both compile time and runtime.

This design ensures:

  • Visibility: You can see exactly what a module can do by reading its capability declarations
  • Security: Modules cannot perform undeclared effects, even if code is injected
  • Auditability: Every effect call is traced with timing and outcome

Capabilities are declared at the top of a module with the capability keyword:

module UserService {
capability http.out("api.example.com")
capability http.out("auth.example.com")
fn fetch_user(id: String) -> Result[String, HttpError] {
http.get("https://api.example.com/users/${id}")
}
}
capability <namespace>.<kind>
capability <namespace>.<kind>(<params>)
CapabilityPurposeParameters
http.outOutbound HTTP requestsHost allowlist (optional)
http.inInbound HTTP handlersRoute prefix (optional)
store.tableDatabase table accessTable name, record type (a declared type with one @primary field)
memory.kvScoped KV memoryNamespace name
modelLLM model accessProvider, model identifier
tool.useTool executionTool name
queue.publishPublish to a named queueQueue name
queue.consumeInbound queue handlersQueue name (optional)
topic.publishPublish to a named topicTopic name
topic.consumeSubscribe to a named topicTopic name
schedule.triggerCron-based schedule handlersCron expression (optional)
process.spawnSpawn supervised processesPool name (optional)
timerOne-shot and recurring timersTimer namespace (optional)
event.logStructured event loggingLog namespace (optional)
uuiduuid.new() — capability-gated randomnessNone
instantinstant.now() — capability-gated clock readsNone

A capability without parameters acts as a wildcard:

module OpenClient {
capability http.out -- allows HTTP to any host
fn fetch(url: String) -> Result[String, HttpError] {
http.get(url)
}
}

Use wildcard capabilities sparingly — explicit host lists are preferred for security.

Effect calls look like regular function calls with a namespace prefix.

http.get(url)
http.post(url, body)
http.put(url, body)
http.patch(url, body)
http.delete(url)
store.users.get(id)
store.users.put(record)
store.users.delete(id)
store.users.query(filter)
memory.put(key, value)
memory.get(key)
memory.delete(key)
memory.list(prefix)

Memory is scoped by namespace, but the namespace never appears at the call site: the capability memory.kv("namespace") declaration names it, and the compiler threads it into every memory call.

llm.chat("claude-opus-4-8", "system prompt", input)
llm.json[RefundDecision]("claude-opus-4-8", "system prompt", input)
llm.stream("claude-opus-4-8", "system prompt", input)
llm.embed("voyage-3-large", input)

llm.chat returns unstructured text. llm.json[T] returns a parsed map constrained by a JSON schema derived from type T at compile time. llm.stream returns the assembled response text after streaming all chunks. llm.embed returns a vector (list of floats) for use in semantic search and RAG patterns. All four require a capability model(...) declaration.

The llm.json[T] syntax takes a type parameter in square brackets. The compiler looks up the type declaration and generates the corresponding JSON Schema, which is passed to the LLM runtime for schema-constrained decoding:

module RefundService {
type RefundDecision {
action: String @one_of(["approve", "deny", "escalate"])
amount: Int @min(0)
reason: String
}
capability model("anthropic", "claude-opus-4-8")
fn decide(ticket: String) -> RefundDecision {
llm.json[RefundDecision]("claude-opus-4-8", "Decide if this warrants a refund.", ticket)!
}
}

The generated schema for RefundDecision includes all type information and constraint annotations:

{
"type": "object",
"properties": {
"action": { "type": "string", "enum": ["approve", "deny", "escalate"] },
"amount": { "type": "integer", "minimum": 0 },
"reason": { "type": "string" }
},
"required": ["action", "amount", "reason"]
}

You can also call llm.json without a type parameter for untyped JSON responses:

llm.json("claude-opus-4-8", "Return JSON.", input)

This uses an empty schema ({}) which accepts any valid JSON.

Inside HTTP handlers, req.json[T] parses the request body as JSON and validates it against the schema derived from type T:

module UserService {
capability http.in
type CreateUser {
email: String
name: String
}
handler http POST "/users" (req) -> {
let user = req.json[CreateUser]!
respond.json(201, user)
}
}

The compiler generates a JSON Schema from CreateUser at compile time. At runtime, req.json[T] parses the request body and validates the required fields and types. It returns {:ok, parsed} or {:error, reason}, making it compatible with the ! (crash) and ? (propagate) unwrap operators.

tool.call(CreateRefund, args)
tool.list()
tool.schema(CreateRefund)

Tool effects require a capability tool.use(ToolName) declaration. See the Tools page for full documentation on tool declarations and calling.

Topics provide pub/sub messaging with fan-out delivery. Every subscriber to a topic receives every published message.

-- Publishing requires topic.publish capability
capability topic.publish("order.events")
topic.publish("order.events", data)

The topic.publish(name, data) effect publishes data to all handlers subscribed to the named topic. Unlike queues (which deliver to a single consumer), topics broadcast to every subscriber.

To consume from a topic, declare a topic.consume capability and a handler topic block:

capability topic.consume("order.events")
handler topic "order.events" (msg) -> {
-- Every subscriber receives every message
respond.json(200, "processed")
}

See the Handlers page for full topic handler documentation.

trace.annotate(key, value) adds key-value metadata to the trace log. Unlike other effects, trace annotations do not require a capability declaration — they are always available in any module, agent, or handler.

module UserService {
capability http.in
handler http GET "/greet/:name" (req) -> {
let name = req.params.name
trace.annotate("user", name)
respond.json(200, "hello")
}
}

Annotations are recorded as trace entries with kind: :annotation alongside regular effect spans. They appear in Skein.Runtime.Trace.recent_spans/1 output interleaved with HTTP, memory, and LLM spans.

In agents, use trace.annotate to enrich trace context with business-relevant data:

agent RefundAgent {
capability model("claude-opus-4-8")
capability memory.kv
enum Phase {
Review -> [Approved]
Approved -> []
}
on start(order_id: String) -> {
memory.put("order_id", order_id)
transition(Phase.Review)
}
on phase(Phase.Review) -> {
let order = memory.get("order_id")!
trace.annotate("ticket_id", order)
let decision = llm.chat("claude-opus-4-8", "Evaluate refund", order)!
trace.annotate("decision", decision)
memory.put("decision", decision)
transition(Phase.Approved)
}
on phase(Phase.Approved) -> {
stop()
}
}

process.spawn spawns a supervised background task. Spawned processes are automatically supervised and traced, providing crash isolation.

module BackgroundWorker {
capability process.spawn("workers")
fn start_task(data: String) -> Result[String, String] {
process.spawn(data)
}
}

Requires a capability process.spawn(...) declaration. Spawned processes run under a DynamicSupervisor with :temporary restart strategy — they are not restarted on crash.

Timer effects schedule one-shot or recurring callbacks:

module TimerService {
capability timer("maintenance")
fn schedule_task() -> Result[String, String] {
timer.after(5000, "callback") -- fires once after 5 seconds
timer.interval(30000, "callback") -- fires every 30 seconds
timer.cancel("timer-ref") -- cancels a timer by ref
}
}
  • timer.after(delay_ms, task) returns Result[String, String] — Ok carries the timer ref
  • timer.interval(interval_ms, task) returns Result[String, String] — Ok carries the timer ref
  • timer.cancel(timer_ref) returns Result[String, String] — idempotent; Ok carries the ref back, Err is a scope-label denial

All three require a capability timer(...) declaration.

event.log(name, data) records a structured event to the event log and returns Result[String, String] (Ok carries the event name; a scope-label denial is an Err). Events have automatic timestamps and unique IDs.

module AuditService {
capability http.in
capability event.log("audit")
handler http POST "/login" (req) -> {
event.log("user.login", req)
respond.json(200, "ok")
}
handler http POST "/action" (req) -> {
event.log("user.action", req)
respond.json(200, "recorded")
}
}

Requires a capability event.log(...) declaration. Events are stored in the unified event store (Skein.Runtime.EventStore) as kind: :user_event entries, queryable alongside trace spans, annotations, and memory state changes via EventStore.query(kind: :user_event).

Effect calls use existing Skein syntax — no special grammar is needed. http.get(url) parses as:

Call {
target: FieldAccess {
subject: Identifier { name: "http" },
field: "get"
},
args: [Identifier { name: "url" }]
}

The analyzer recognizes this pattern and checks it against declared capabilities.

HTTP effect calls return Result[HttpResponse, HttpError]. Since C2 (#297) the error side is the frozen structured-error ABI, not a bare string:

  • {:ok, response} on success — an HttpResponse record with status, body, and headers
  • {:error, <HttpError variant>} on failure — Timeout, ConnectionFailed, Status(code, body) for non-2xx responses, InvalidRequest(reason), or Denied(reason) for capability violations; each lowers to its snake_case tuple (e.g. {:status, 404, body}, {:denied, reason}) so Skein Err(HttpError.Status(code, body)) arms really match

Memory effect calls return Result tuples typed Result[T, MemoryError]:

  • memory.get returns {:ok, value} or {:error, :not_found} — matched as Err(MemoryError.NotFound) (or the bare Err(NotFound)) in Skein
  • memory.put returns {:ok, value}
  • memory.delete returns {:ok, key}
  • memory.list returns a list of matching keys
  • a namespace denial is {:error, {:denied, reason}} — Err(MemoryError.Denied(reason))

LLM effect calls return Result tuples typed Result[_, LlmError]. Since C2 (#297) the error side is the frozen structured-error ABI: nominal enum variants that lower to snake_case tuples, so a Skein Err(LlmError.ProviderError(code, message)) arm really matches.

  • llm.chat returns {:ok, response_text} or {:error, <LlmError variant>} — e.g. {:rate_limit, retry_after_ms}, {:provider_error, code, message}, {:denied, reason}
  • llm.json returns {:ok, parsed_map} or {:error, <LlmError variant>}
  • llm.stream returns {:ok, assembled_text} or {:error, <LlmError variant>} (chunks delivered via callback at runtime)
  • llm.embed returns {:ok, [float()]} or {:error, <LlmError variant>} (vector dimensionality depends on model)

The full variant list for every effect error enum (HttpError, LlmError, ToolError, StoreError, MemoryError, PublishError) is in spec §6 and pinned by Skein.EffectABI.error_enums/0.

The analyzer’s capability checking pass (Pass 3) walks every function body looking for effect calls. When it finds one, it checks that the module declares the required capability.

module BadService {
fn fetch(url: String) -> Result[String, HttpError] {
http.get(url) -- ERROR: E0012
}
}

Produces:

{
"code": "E0012",
"severity": "error",
"message": "Capability 'http.out' required but not declared. Effect calls to 'http' require this capability.",
"fix_hint": "Add a capability declaration to the module: capability http.out",
"fix_code": "capability http.out"
}

The fix_code field gives agents the exact text to add to fix the error.

The analyzer detects effect calls in all positions:

  • Top-level expressions in function bodies
  • Inside let bindings: let result = http.get(url)
  • Inside match arms: true -> http.get(url)
  • Inside pipe chains: url |> http.get() (the piped value becomes the call’s first argument)
  • Nested within other expressions

Even if the compiler is bypassed, the runtime enforces capabilities as a second layer of defense.

When compiled code calls http.get(url), the code generator emits a call to Skein.Runtime.Http.get(url, capabilities) where capabilities is the module’s declared capability list. The runtime checks the URL’s host against this list before making the request.

-- This compiles, but the runtime blocks it:
-- The URL host "api.blocked.com" doesn't match "api.allowed.com"
module Service {
capability http.out("api.allowed.com")
fn fetch(url: String) -> Result[String, HttpError] {
http.get("https://api.blocked.com/data")
}
}
  1. The code generator embeds capabilities in each module via __capabilities__/0
  2. Effect calls compile to runtime module calls with capabilities as a parameter
  3. The runtime validates the URL host against declared hosts
  4. Blocked requests return {:error, {:denied, "Host 'x' not declared in http.out capabilities"}} — Err(HttpError.Denied(reason)) in Skein

Every effect call is automatically traced. The runtime records:

FieldDescription
kindEvent type (:http, :memory, :llm, :store, :tool, :annotation, :user_event, :state_change, :process, :timer)
methodOperation (:get, :post, :put, :chat, :json, :embed, :call, etc.)
urlTarget URL (HTTP)
namespaceMemory namespace (Memory)
modelLLM model name (LLM)
nameTool name (Tool)
keyAnnotation key (Trace annotations)
valueAnnotation value (Trace annotations)
duration_usWall-clock duration in microseconds
outcome:ok or :error
timestampMonotonic timestamp

All events are stored in the unified event store (Skein.Runtime.EventStore) and can be queried via EventStore.query/1, EventStore.recent/1, or Trace.recent_spans/1. Memory state can be reconstructed from :state_change events via Memory.rebuild_from_events/1.

A module with capabilities compiles to a BEAM module that includes:

  1. All user-defined functions (as before)
  2. __info__/1 for Elixir compatibility (as before)
  3. __capabilities__/0 — returns the declared capabilities as a list of maps
# Calling __capabilities__/0 on a compiled module:
Skein.User.MyService.__capabilities__()
#=> [%{kind: "http.out", params: ["api.example.com"]}]

Effect calls in function bodies compile to remote calls:

-- Skein source
http.get(url)
-- Compiles to (conceptually):
Skein.Runtime.Http.get(url, [%{kind: "http.out", params: ["api.example.com"]}])

The idempotent(key) expression is a built-in guard for handler bodies that ensures exactly-once processing. It does not require a capability declaration — it is available in any handler.

handler queue "events" (msg) -> {
idempotent(msg.id)
-- rest of handler only runs once per unique msg.id
}

The idempotency guard is backed by Skein.Runtime.Idempotent, which tracks processed keys in an ETS table with a configurable TTL (default: 1 hour). See the Handlers page for full details.