System/Software Hardware Abstraction Layer — device-tree-inspired, dynamic, user-space, network-capable
Project description
SHAL
Give an AI agent real hardware — gated, so it asks before it moves.
Describe your whole lab — sensors, instruments, robots, services — in one YAML file, and hand it to an LLM as typed, permission-gated tools: writes stop for approval before they reach the device; reads don't. No transport code, no glue.
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.
→ Try it in 60 seconds — no hardware required
Built for:
✓ AI agent builders · ✓ Validation & test engineers · ✓ Hardware-in-the-loop automation · ✓ Labs with mixed hardware + software
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; [pkg] is a driver you install or write.
# 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 } # [core]
instruments: # raw-socket SCPI bus [pkg]
driver: acme,scpi
address: 10.0.0.50:5025
children:
supply: { id: dut_power, driver: keysight,e36312, address: ch1 } # [pkg]
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 } # [pkg]
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
txnid 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. Dependencies: pyyaml, jsonschema.
Quick Start
Runs with zero hardware — the simulated bus ships with SHAL. New to hardware? This is the whole setup, and it's just Python and a YAML file.
# sim.yaml
shal_version: 1
root:
bus:
driver: shal,sim-i2c
address: sim0
children:
temp0:
id: ambient_temp
driver: ti,tmp102
address: 0x48
import shal
with shal.load("sim.yaml") as hal:
print(hal.get_device("ambient_temp").read_celsius()) # 25.0
$ python quickstart.py
25.0
When the real board arrives, change shal,sim-i2c → shal,i2c-cli. Your
Python doesn't change.
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-mcp", "args": ["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 entire bundled temperature-sensor driver:
from shal import Driver, TemperatureSensor, registry, idempotent, op, ByteTransport, Read, Write
@registry.register
class Tmp102(Driver, TemperatureSensor):
compatible = "ti,tmp102" # matched against the YAML `driver:` field
kind = ByteTransport
llm_ready = True
@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
That's it — register the compatible, implement the capability. The @op
metadata is what makes it show up as a gated agent tool. SHAL discovers your
driver via the shal.drivers entry point.
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, reusabletemplate:includes - ✅ Bundled buses:
sim-i2c,local,ssh-host,i2c-cli,spi-cli,tcp(TLS),http,nxp,pca9548mux - ✅ 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
- Driver SDK — the complete authoring contract (write a driver from docs alone)
- Driver / bus catalog
- Architecture & locked decisions
- Phase 1 implementation decisions
- Phase 2 async + watchdog spec
- Build guides: write a driver, a bus, or a topology
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.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyshal-0.2.0.tar.gz.
File metadata
- Download URL: pyshal-0.2.0.tar.gz
- Upload date:
- Size: 104.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ce819c4611fc95e63fffa8ece352a88f719cc798764cf32d45fd98d358c6f733
|
|
| MD5 |
bc564dd70d0d775308e5ad94f21d952e
|
|
| BLAKE2b-256 |
3b18a0192f85d935dd2f21c8eb399c6030e17b7a92559eb9d1ee8ceae6b28ea8
|
Provenance
The following attestation bundles were made for pyshal-0.2.0.tar.gz:
Publisher:
release.yml on determlab/shal
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyshal-0.2.0.tar.gz -
Subject digest:
ce819c4611fc95e63fffa8ece352a88f719cc798764cf32d45fd98d358c6f733 - Sigstore transparency entry: 1896742326
- Sigstore integration time:
-
Permalink:
determlab/shal@228ce7f83b790fa845f9f088e6b452026143bb2d -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/determlab
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@228ce7f83b790fa845f9f088e6b452026143bb2d -
Trigger Event:
release
-
Statement type:
File details
Details for the file pyshal-0.2.0-py3-none-any.whl.
File metadata
- Download URL: pyshal-0.2.0-py3-none-any.whl
- Upload date:
- Size: 83.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4527fb2e8823780e421e1be7a4aa6a00faf1115ee22a9534096e6aaa9db57fa1
|
|
| MD5 |
530a04624bff205bcaf9f43534046d55
|
|
| BLAKE2b-256 |
3c716323c4a21aa905cdd6dc1a3b6d68a959b35d356abe40c4b22c86dc823834
|
Provenance
The following attestation bundles were made for pyshal-0.2.0-py3-none-any.whl:
Publisher:
release.yml on determlab/shal
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyshal-0.2.0-py3-none-any.whl -
Subject digest:
4527fb2e8823780e421e1be7a4aa6a00faf1115ee22a9534096e6aaa9db57fa1 - Sigstore transparency entry: 1896742547
- Sigstore integration time:
-
Permalink:
determlab/shal@228ce7f83b790fa845f9f088e6b452026143bb2d -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/determlab
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@228ce7f83b790fa845f9f088e6b452026143bb2d -
Trigger Event:
release
-
Statement type: