Skip to content

Composition

Real systems are not single machines. They are compositions: one machine calling another, each with its own contract, governance, and audit trail. In mashin, composition is a first-class concept. You use ask ... from to call another machine the same way you call a stdlib effect machine. The called machine runs under its own governance rules, and the boundary between caller and callee is clean.

Calling another machine

ask validate, from: "@myorg/orders/validate"
order_id: input.order_id
returns
valid as boolean
errors as list
assuming
valid: true
errors: []

The from parameter takes a machine path. Three namespaces:

NamespaceExampleMeaning
@mashin/actions/*@mashin/actions/http/getOfficial standard library
@orgname/*@myorg/billing/chargeOrganization machines
bare name"data_enricher"Local machine in the same project

A composed pipeline

Here is an order processing pipeline built from three machines:

machine process_order
accepts
order_id as text, is required
customer_id as text, is required
responds with
status as text
shipping_estimate as number
ensures
permissions
allowed to
machine.call
implements
ask validate, from: "@myorg/orders/validate"
order_id: input.order_id
returns
valid as boolean
errors as list
assuming
valid: true
errors: []
decide check_validation
when steps.validate.valid
ask ship, from: "@myorg/shipping/calculate"
order_id: input.order_id
customer_id: input.customer_id
returns
cost as number
days as number
assuming
cost: 9.99
days: 3
otherwise
compute reject
{status: "rejected", shipping_estimate: 0}
compute result
{
status: "approved",
shipping_estimate: steps.ship ? steps.ship.days : 0
}

Each ask ... from call invokes an independent machine. @myorg/orders/validate has its own accepts, ensures, and implements. When it runs, its governance rules apply. The caller’s trust level constrains what the callee can do (trust ceiling), but governance does not leak across boundaries.

The encapsulation principle

When a machine grows past 5-6 steps, look for groups of related steps that can be extracted into submachines. The parent should read as a high-level narrative:

// Before: 8 steps in one machine
implements
ask fetch_tickets, from: "@mashin/actions/http/get"
url: input.api_url
compute parse_tickets
{parsed: steps.fetch_tickets.body}
ask classify, using: "anthropic:claude-haiku-4-5"
with task "Classify these tickets"
compute filter_urgent
{urgent: steps.classify.items.filter(i => i.priority == "urgent")}
ask assign_agents, from: "agent_lookup"
tickets: filter_urgent.urgent
compute match_assignments
{matched: steps.assign_agents.assignments}
ask notify_slack, from: "@mashin/actions/http/post"
url: "https://slack.com/api/notify"
compute done
{status: "complete"}
// After: parent reads as a story
implements
ask get_and_classify, from: "@myorg/support/classify_tickets"
api_url: input.api_url
ask assign, from: "@myorg/support/assign_agents"
tickets: steps.get_and_classify.urgent
ask notify, from: "@myorg/support/notify_team"
assignments: steps.assign.assignments

Each submachine is independently testable and potentially reusable.

Governance across boundaries

When machine A calls machine B:

  1. Machine A needs machine.call permission (or the specific capability required by B)
  2. Machine B runs under its own governance rules
  3. A’s trust level is the ceiling for B (B cannot exceed A’s privileges)
  4. Each machine’s behavioral ledger is independent
  5. The call itself is recorded in A’s ledger (target, inputs, result)

This is transitive governance. You do not need to redeclare B’s permissions inside A. Each machine is self-governing.

Passing data

Input keys in ask ... from are passed as the called machine’s accepts fields:

// Caller
ask enrich, from: "customer_enricher"
customer_id: input.id
include_history: true
// Called machine
machine customer_enricher
accepts
customer_id as text, is required
include_history as boolean, default: false

Testing composed machines

Use assuming to mock the called machine’s response in tests:

ask validate, from: "@myorg/orders/validate"
order_id: input.order_id
assuming
valid: true
errors: []

In test mode, the called machine is never invoked. The assuming values are returned immediately. This means you can test the caller’s logic without running (or even having access to) the callee.

Try it

Build a three-machine pipeline: one machine that fetches data, one that classifies it with an LLM, and a parent machine that orchestrates them. Use ask ... from in the parent to call the other two. Add assuming blocks so the parent can be tested independently.

Next steps