Skip to main content

type: readme owner: CMO scope: repo/shal reviewed: 2026-06-23

SHAL

A test sequencer an agent can drive — safely.

One file describes everything your test touches — the board on the bench, the database behind it, the service it calls — with the risk of each operation written down. Run it from Python or pytest at the bench. Hand it to an AI agent as typed, gated tools: reads run free; anything that moves or reconfigures stops for a person.

Behind the door: SHAL is the capability layer for AI agents operating engineering and production systems — hardware and software through one governed interface. Testing is the first use. The moat is one word: physical.

In a blind test, an agent wrote working, safety-checked drivers for 4 of 4 devices from documentation alone — then drove a real robot, gated. Founder-run, not yet independently replicated.

GitHub stars PyPI Python License Status

SHAL turns your lab and services into one YAML topology — controlled from Python or exposed to an AI agent as typed, gated tools

→ Try it in 60 seconds — no hardware required

Built for:

✓ Validation & test engineers who already write Python  ·  ✓ Production test stations  ·  ✓ Labs with mixed hardware + software  ·  ✓ AI agent builders who need a gate before the wire


From glue scripts to agent tools — in three steps

Step 1 · Today, without SHAL — a separate library, address, and retry per device:

sensor  = TMP102(i2c_bus, 0x48)
supply  = SCPIPowerSupply("10.0.0.50:5025")
results = RESTClient("https://mes.lab.internal")
# ...and you wire each one's retries, logging, and tool-wrapper by hand

Step 2 · With SHAL — describe the rack once, then call devices by name:

hal = shal.load("lab.yaml")

hal.get_device("ambient_temp").read_celsius()       # I²C sensor
hal.get_device("dut_power").set_voltage(3.3)        # SCPI supply
hal.get_device("results_db").record(status="pass")  # HTTP service

Validation & test engineers can stop here. One model for the whole rack — no agent needed, no transport code, no glue.

Step 3 · Hand the same rack to an agent — the tool catalog is generated for you:

tools = hal.tool_schemas()                           # one typed tool per device op
hal.call_tool("dut_power__set_voltage", {"volts": 3.3})

Writes are gated, reads aren't. The agent never sees SCPI, I²C, or an address.


Why existing agent frameworks fall short

Most agent tooling assumes software-only tools: APIs, databases, functions. The moment a tool is a physical device — a sensor on I²C, an instrument over a raw socket, a robot behind a network hop — you're on your own.

SHAL exposes physical devices, remote labs, instruments, and software services as the same kind of tool — with the safety rails physical actions need: gated writes, honest failure, a full audit trail.


One model for hardware and software

The core idea is small:

A bus is just a node that provides a transport to its children.

A sensor on I²C and an HTTP service are the same kind of node. Your code — and your agent — calls capabilities (read_celsius(), set_voltage()), never transports. [core] ships with SHAL — the buses. [you] is a device driver or a vendor bus: you write it, or install one someone else wrote.

# lab.yaml — hardware and software in ONE graph
shal_version: 1
root:
  bench:                         # one SSH hop to the bench controller       [core]
    driver: shal,ssh-host
    address: ${BENCH_SSH}        # secrets resolve from the environment, never logged
    children:
      i2c0:                      # I²C rendered as argv over the SSH hop      [core]
        driver: shal,i2c-cli
        address: /dev/i2c-1
        children:
          ambient: { id: ambient_temp, driver: ti,tmp102, address: 0x48 }   # [you]

  instruments:                   # raw-socket SCPI bus                        [you]
    driver: acme,scpi
    address: 10.0.0.50:5025
    children:
      supply: { id: dut_power, driver: keysight,e36312, address: ch1 }       # [you]

  services:                      # HTTPS to internal services                [core]
    driver: shal,http
    address: https://mes.lab.internal
    children:
      results: { id: results_db, driver: acme,mes-results, address: api/v2 } # [you]

Every node is reached the same way — hal.get_device("dut_power").set_voltage(3.3) — sensor or database, local or across the network. Same retries, same logs. Swap any node for its sim and nothing in your code changes.


Features

  • Agent-native — every device op becomes a gated LLM tool.
  • Asks before it moves — actuator & destructive/config ops stop for a host-supplied approver (CLI prompt, an agent, or auto in sim/CI); the gate is pre-I/O and unbypassable.
  • Hardware + software, one graph — a sensor and an HTTP service are the same node.
  • Capabilities, not wires — call read_celsius(), never I²C.
  • Retry you can trust — reads auto-retry; risky writes never silently repeat.
  • Sim-first — test the whole rack with zero hardware.
  • Recursive — muxes, jumpboxes, nested buses: one primitive, no special cases.
  • Drivers as plugins — add a device in one small class.
  • Secure by default — no shell strings, TLS on, secrets via ${ENV}.
  • Observable — structured logs, one txn id per call.

Install

SHAL is in alpha (Phase 1).

pip install pyshal      # package is `pyshal`; you import it as `shal`
import shal

For development:

git clone https://github.com/determlab/shal && cd shal
pip install -e ".[dev]"   # pytest, ruff

Requires Python ≥ 3.10 for SHAL core. A device's own library may need newer — the featured Deebot path (deebot-client) needs 3.11+ (asyncio.TaskGroup); build that venv on 3.11+. Dependencies: pyyaml, jsonschema.


Quick Start

Two commands and one file. No hardware, no account, no API key.

pip install pyshal

⚠️ Until 0.3.0 is published, install from source instead: pip install "git+https://github.com/determlab/shal"

PyPI's latest is 0.2.2, and it does not have the shal,sim-sensor device this page reads — so on 0.2.2 the next command fails with no driver installed for compatible 'shal,sim-sensor'. Delete this note when 0.3.0 ships (#184).

Save this as sim.yaml. It is the whole setup: a bus, and one device on it.

# sim.yaml
shal_version: 1
root:
  bus:
    driver: shal,sim-i2c
    address: sim0
    children:
      temp0:
        id: ambient_temp
        driver: shal,sim-sensor
        address: 0x48

Now read it:

shal probe sim.yaml
# 1 read(s), 1 write(s) on this topology
ambient_temp__read_celsius: 25.59
# writes — not run by `shal probe`; use `shal call` (gated ops are refused until approved): ambient_temp__set_target

That is the product in one command. The read ran. The write did not. set_target changes the device, so SHAL will not run it without a person — and it says so instead of doing it quietly. Run the command again and the reading moves: it is a simulated room that drifts, not a fixed number.

Now try the write yourself:

shal call sim.yaml ambient_temp set_target 30
shal call: refused: ambient_temp.set_target is labelled 'config'. A 'config' op needs a person's approval, and shal call cannot give it. Nothing was sent to the device.
  To run it with approval, either:
    - serve the topology to an MCP host:  shal mcp sim.yaml
      (the host shows the call to a person, who approves it), or
    - in Python:  with shal.approver(<your Approver>): hal.get_device('ambient_temp').set_target(...)
  There is no --approve flag: the agent that runs a command cannot approve its own call.

It exits 2 and nothing reached the device. A read of the same device runs normally — shal call sim.yaml ambient_temp read_celsius prints a number.

That last line is the design: the thing making the call is never the thing that approves it. Your driver declares each operation's label; nothing infers it, and no flag overrides it. shal tools sim.yaml lists every operation with its label if you want to see them all.

From your own code

The same setup, from Python. Save this as quickstart.py:

import shal

with shal.load("sim.yaml") as hal:
    print(hal.get_device("ambient_temp").read_celsius())
python quickstart.py

Next

Samples — small programs that show what SHAL does. They ship inside the package, so there is nothing to clone and no network needed:

shal docs --samples                     # what there is
shal docs --sample limits --to mydir    # write one out, and print how to run it

limits is the one to read first: it measures, prints PASS, measures against a tighter limit and prints FAIL, then tries to change the device — and the gate stops it. Run it in a terminal and it asks you for permission. Every sample runs in CI on Linux and Windows from a clean install, so what you read here is what runs.

shal docs prints the authoring guide that ships inside the package — how to add your own device, with no network and nothing to clone.

When you move to real hardware you swap the bus for a real one (shal,i2c-cli, shal,ssh-host, shal,tcp) and name a driver for your actual chip — shal,sim-sensor is a simulated device, not a real part. Your Python does not change.


Reuse a board — use: and with:

A bench description isn't text you paste into the next project. It's a file the next project points at.

Describe the board once, with ${param} placeholders for whatever differs:

# board.yaml
shal_version: 1
template:                       # a `use:` target must define `template:`
  driver: shal,sim-i2c
  address: sim0
  children:
    temp:
      id: ${prefix}_temp
      driver: shal,sim-sensor
      address: 0x48

Then reference it as many times as you need:

# rig.yaml
shal_version: 1
root:
  bench_a: { use: board.yaml, with: { prefix: a } }
  bench_b: { use: board.yaml, with: { prefix: b } }
import shal

with shal.load("rig.yaml") as hal:
    print(hal.get_device("a_temp").read_celsius())
    print(hal.get_device("b_temp").read_celsius())

Both benches come up as separate devices, and each op becomes its own agent tool (a_temp__read_celsius, b_temp__read_celsius).

The second bench is a reference, not a copy — fix board.yaml and every rig that uses it is fixed.

Details worth knowing:

  • ${param} substitutes into every string in the template, including id. That is how you avoid the global duplicate-id error when the same board appears twice: give each instance its own prefix.
  • The using node's own keys override the template's, so a rig can change one address without editing the board file.
  • Includes chain — a template may itself be a use: node. Cycles are detected and refused.
  • Paths are confined to the topology root. A use: that escapes it via ../ is an error, not a warning.
  • Values not supplied by with: fall back to ${ENV_VAR} resolution.

Drive it from an agent (MCP)

Expose a whole topology to an MCP host (Claude Code/Desktop, …) as gated tools — no glue:

pip install "pyshal[mcp]"
shal mcp lab.yaml            # reads run free; writes ask a human first

Register it with your host (example mcpServers block):

{"mcpServers": {"shal": {"command": "shal", "args": ["mcp", "lab.yaml"]}}}

Now tell the agent "read the DUT temperature" (runs immediately) or "set 3.3 V" — a write pauses: the agent gets an approval_required ticket and a human approves the shal_approve tool before anything reaches hardware. Opt into free writes with --approve auto (the choice is recorded in the audit log).

Already own a device with a Python library? Wrapping it as a SHAL driver is a few lines — see the ready-to-edit examples in examples/demos/ (a Sonos speaker, a Deebot vacuum), then point SHAL at your topology:

shal probe my-setup.yaml   # one-shot: print your devices' state, then exit
shal mcp   my-setup.yaml   # or serve it to an AI host (writes gated)

Write a driver in 30 seconds

Need a device SHAL doesn't have yet? A driver is one small class. This is the shape:

@registry.register
class MyChip(Driver, TemperatureSensor):
    compatible = "acme,mychip"        # matched against the YAML `driver:` field
    kind = ByteTransport

    @idempotent                        # a read: safe to auto-retry across drops
    @op("Read the ambient temperature now.", unit="celsius", side_effect="none")
    def read_celsius(self) -> float:
        raw = self.bus.txn(self.addr, [Write(b"\x00"), Read(2)])
        return ((raw[0] << 4) | (raw[1] >> 4)) * 0.0625

Register the compatible, implement the capability, and label every op: side_effect="none" runs freely; "config" and "actuator" stop for a person. That label is the only thing standing between an agent and your hardware, so you declare it — nothing infers it.

Working references ship inside the package, complete with a simulator, tests and a topology:

shal docs --list                # the references you can read
shal docs --example tmp102      # print one — driver, sim twin, test, topology

They are guide material, not registered drivers: copy the four files and edit, or run one as it stands with --drivers. shal docs prints the full authoring guide, and shal check <compatible> tells you what your driver is still missing.


How It Works

A topology is a tree, and every edge is a bus — itself a node that carries traffic to its children. You call a capability; SHAL translates it down the stack to the wire and hands the result back up. No layer leaks into the one above.

sequenceDiagram
    participant U as Your code
    participant T as tmp102 driver
    participant I as i2c-cli bus
    participant S as ssh-host bus
    U->>T: read_celsius()
    T->>I: read register 0x00
    I->>S: i2ctransfer 0x48 … (argv)
    Note over S: runs on lab_server
    S-->>I: raw bytes
    I-->>T: raw bytes
    T-->>U: 22.5 °C

Because every hop is the same primitive, an SSH jumpbox, an I²C mux, and an in-process sim all compose — no special cases.


Core Concepts

Concept What it means
Node Anything in the tree: a device, a bus, a board.
Bus A node that provides a transport to its children (I²C, SSH, HTTP…).
Driver Bound to a node by its compatible string. Implements a capability.
Capability The typed API your code calls (read_celsius()), independent of transport.
id vs path id is a stable name for lookup; path is where it sits. Move a device, keep its id.
hal.get_device("ambient_temp").read_celsius()   # by semantic id — no wires leak in

Real-World Use Cases

  • AI agents with real-world access — expose a lab or robot to an LLM as gated tools; every actuator call stops for a human (or policy) to approve before it fires, and a delivery-unknown write is never silently retried.
  • Validation & test racks — one model for eval boards, instruments, and the results database; test against sims in CI before hardware.
  • Manufacturing lines — same capability calls across stations; one audit trail (shal.audit) for every actuator command.
  • Remote & distributed setups — drive hardware behind an SSH jumpbox with nothing on the far side but standard CLI tools.
  • Robotics bringup — start against a sim, swap in transports as boards land, without rewriting control code.

FAQ

Why not just wrap Python libraries as agent tools myself? You can — until there are ten devices on four transports, some behind an SSH hop, some not. Then you're hand-maintaining a tool wrapper, address, retry policy, and audit log per device. SHAL generates all of it from one topology.

Is it production-ready? It's alpha (Phase 1). The synchronous core — topology, drivers, buses, retry policy, and the agent tool surface — is real and tested. Async/streaming, the actuator watchdog, and route failover are Phase 2 (roadmap).

Do I need real hardware to try it? No. The bundled simulated bus runs the Quick Start with zero hardware — swap in a real transport later, and your code doesn't change.


Roadmap

Shipped — Phase 1 (synchronous core, v0.1.0):

  • ✅ Declarative YAML topology: JSON-Schema validation, id/path/$ref, ${ENV} secrets, reusable template: includes
  • ✅ Bundled buses: sim-i2c, local, ssh-host, i2c-cli, spi-cli, tcp (TLS), http, and the mux mechanism (the nxp,pca9548 chip itself is an example, not a shipped driver)
  • ✅ Bundled devices: shal,sim-sensor — the simulated device the Quick Start reads. No vendor part ships as a registered driver
  • ✅ Capability model, driver plugin registry, trustworthy retry policy
  • ✅ Agent tool surface: tool_schemas() / tool_catalog() / call_tool()
  • ✅ Human-in-the-loop actuation gate: actuator ops stop for an injectable Approver (pre-I/O, unbypassable, every decision audited)
  • ✅ Structured observability + capture() flight recorder

Designed, in progress — Phase 2:

  • 🚧 Async / streaming (subscribe, held channels) — spec
  • 🚧 Actuator watchdog & safe-state (timeouts, auto safe-state on disconnect)
  • 🚧 Route failover for multi-path devices

Documentation


Contributing

Contributions welcome — new drivers and buses especially. A driver is one small class (see above); SHAL discovers it via the shal.drivers entry point.

pip install -e ".[dev]"
python -m pytest          # test suite
ruff check src tests      # lint

See CONTRIBUTING.md for the full guide.


License

MIT.

Metadata

Release files for pyshal 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for pyshal 0.3.0
File Size Uploaded
pyshal-0.3.0.tar.gz 211.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for pyshal 0.3.0
File Interpreter ABI Platform
pyshal-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 390.9 kB

Release files / pyshal-0.3.0.tar.gz

Download URL pyshal-0.3.0.tar.gz
Size 211.2 kB
Tags Source
SHA-256 checksum
How to use checksums
8918c3bd7e790610f07df9842296a576115a575c42fcc94e1296c91550e1e82d
BLAKE2b-256 checksum
How to use checksums
ee23f00cdafed0778c7ecfcef66fa1dfc49cdb40d29bc750ccd1f4767a372308
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

Release files / pyshal-0.3.0-py3-none-any.whl

Download URL pyshal-0.3.0-py3-none-any.whl
Size 179.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
a7f7d750b8deb51ecb0027d9f2bc2643838d930ae0972f37bb9f17bae53e4260
BLAKE2b-256 checksum
How to use checksums
e0ad8568c7b3c09b7964662cc5a368903483e086de7c6d59eb1ef0696748e15e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.3.0 This release

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page