thingctx
One description, and your model reads from and acts on a real device, sensor, tool, or service, directly. No per-integration server.
thingctx.com: browse real services (GitHub, Stripe, Slack, and more) as ready-to-use Thing Descriptions.
thingctx uses the W3C Web of Things standard as a uniform interface between an AI app and the systems it reaches. Point it at a Thing Description and it drives the actual Thing the description names, over that Thing's own transport. The integration is a document, not a server you run.
A "Thing" is anything with a callable interface, not just hardware: a sensor
or a robot, but equally a REST API, a database, a SaaS product, an internal
service. A Thing Description (TD) is plain JSON that names that system's
actions (things to do), properties (state to read or write), and events
(things to subscribe to), plus the transport for each (HTTP, MQTT, local, and
more). thingctx reads it, hands the actions to your model as tools, and calls
each against the real system. The system's own endpoints are the server; you
write nothing server-side.
A whole TD can be this small (a weather API, no hardware in sight):
{
"@context": "https://www.w3.org/2022/wot/td/v1.1",
"id": "urn:example:weather:v1",
"title": "Weather",
"securityDefinitions": { "bearer_sc": { "scheme": "bearer" } },
"security": ["bearer_sc"],
"properties": {
"temperature": { "type": "number", "readOnly": true,
"forms": [{ "href": "https://api.example.com/temp" }] }
},
"actions": {
"forecast": {
"input": { "type": "object", "properties": { "city": { "type": "string" } } },
"forms": [{ "href": "https://api.example.com/forecast", "htv:methodName": "POST" }]
}
}
}
Point an agent at it:
import thingctx
host = await thingctx.from_url("https://api.example.com/.well-known/wot")
print(await host.chat("what's the forecast for Cairo, and the current temperature?"))
The model picks the actions; thingctx routes each to its transport.
Install
pip install thingctx[all] # litellm + httpx + paho-mqtt + jsonschema + mcp + azure-identity
# or pick extras: thingctx[llm] [http] [mqtt] [validate] [mcp] [authz] [entra]
The base install is dependency-free, including the authorization seam
(thingctx.authz). The authz extra adds the token guard (pyjwt + httpx); the
entra extra adds the Entra provider (azure-identity). Nothing heavy ever lands
in the base install.
Drive it directly
Own the agent loop? Read a description, get the tool specs to hand your model, and route each call back to the Thing. Nothing in between.
import thingctx
client = thingctx.ThingClient.from_registry(
thingctx.from_arg("http://device.local/.well-known/wot")) # a URL, folder, or TDD
specs, invoke = client.as_tools() # specs for your model; invoke(name, args) runs a call
await invoke("pump.set_speed", {"rpm": 1500})
await client.read_property("pump.rpm")
Add a Thing by pointing at one more description.
Safe by default: approval + grounding
Two opt-in layers stand between an agent and a real system.
Approval gates risky calls behind a human or a policy. Risk is read from the
TD (tc:requiresApproval, or @type tc:Destructive) and from a policy you pick:
def approve(req): # sync or async; return True to allow
return input(f"run {req.tool_name}{req.arguments}? [y/N] ").lower() == "y"
client = thingctx.ThingClient(
tds=[td], bindings=[...], approve=approve, approve_when="declared")
await client.invoke("pump.estop") # asks approve() first; if denied, never runs
approve_when is declared (default, only TD-marked risky actions),
destructive (the above plus any non-idempotent action and every property
write), all, or never. A gated call with no approver is denied , a gate
with nobody to open it stays shut. The check sits in ThingClient.invoke, so it
applies to the LLM loop and to direct callers alike.
Grounding checks a description against the live Thing before you trust it.
verify() reads every readable property and confirms it answers and matches its
declared type. It is read-only and safe , actions are never invoked.
for report in await client.verify():
assert report.ok, report.as_dict()
The gate is on ThingClient.invoke, so it holds for any caller , a hand loop,
the LLM host, or an MCP client (Claude/Copilot CLI; see
Reach a closed agent below).
Runnable: examples/04_trust.py. Full model:
docs/USAGE.md.
Authorization: who may do what
Approval asks a human. Authorization decides from policy, per caller, per operation, before the device is touched. thingctx separates two things that are usually blurred together:
- Authentication (authn) proves who the caller is. It validates a bearer
token into a claims dict. It needs crypto, so it lives in
thingctx.identitybehind theauthzextra (or an upstream gateway does it for you). - Authorization (authz) decides what that caller may do. It runs on the
dependency-free core (
thingctx.authz): no crypto, no network. It takes an already-validated identity and answers permit or deny for each(thing, affordance, operation).
That split is why the core stays dependency-free while still enforcing: it never validates a token, it consumes an identity someone already validated.
Authorization is native to ThingClient. Pass a pdp and an identity, and
every device-reaching call authorizes before it selects a transport:
from thingctx import LocalBinding, ThingClient
from thingctx.authz import LocalPolicyGrantSource, PolicyDecisionPoint, build_vocabulary
vocab = build_vocabulary(ThingClient(tds=[td], bindings=[...]).things)
# the operator role may READ the setpoint, not WRITE it
grants = LocalPolicyGrantSource({"operator": {(thing_id, "target_rpm", "readproperty")}})
pdp = PolicyDecisionPoint(vocabulary=vocab, grant_source=grants)
client = ThingClient(tds=[td], bindings=[...], pdp=pdp, identity=claims)
await client.read_property("pump.target_rpm") # ALLOWED
await client.write_property("pump.target_rpm", 3000) # AuthorizationDenied, device untouched
The decision is TD-closed: a grant is honored only if the TD's forms actually declare that operation, so a wildcard grant can never permit an operation no form exposes, and a read-only property can never be written. The check is at the dispatch layer, below the transport, so a multi-transport Thing cannot be reached around it, and streams (observe, event, media) are authorized at subscribe time and per delivery, so a stream stops the moment the token expires.
The PDP is pluggable. The lean local one ships by default; the same seam speaks the OpenID AuthZEN 1.0 wire format, so you can point at your own OPA, Cedar, or enterprise PDP without changing thingctx.
Runnable: examples/14_authz.py (authz on core alone)
and examples/15_authn_to_authz.py (a real
token validated by the guard, then enforced). Full model:
docs/SECURITY.md.
Trusting the description itself. A TD carries no secrets, so it is safe to
commit and share, and thingctx fetches only over http(s) and validates a TD
against the W3C TD 1.1 schema before using it (install thingctx[validate]). But
a description you fetch is code your agent acts on, so treat its source the way you
treat any dependency: prefer a TD you wrote or a directory you control over an
arbitrary URL. Content pinning and signature verification (so a fetched TD must
match a known digest or a trusted signer) are a design in progress, not yet shipped;
until then, the trust boundary is the source you point thingctx at.
Reach a closed agent: the MCP bridge
Some agents are closed: you can't hand their model tools directly, only through MCP (Claude Desktop, the Claude CLI, Copilot). For those, thingctx ships one generic MCP server that turns a registry of descriptions (a folder, a URL, or a W3C Thing Description Directory) into MCP tools, with no per-integration server.
pip install "thingctx[mcp,http]"
thingctx-mcp ./examples/registry/ # a folder, a URL, or a TD Directory
{ "mcpServers": { "things": { "command": "thingctx-mcp",
"args": ["./examples/registry/"] } } }
Risky tools are gated here too (see Safe by default
above): the bridge sends MCP destructive hints and asks the client to confirm a
gated call (elicitation); decline , or a client that can't ask , means denied.
Pick the policy with THINGCTX_APPROVE_WHEN (declared default, or
destructive / all / never):
{ "mcpServers": { "things": { "command": "thingctx-mcp", "args": ["./examples/registry/"],
"env": { "THINGCTX_APPROVE_WHEN": "destructive" } } } }
MCP is just one way to deliver the description, for agents where direct tool calling isn't available.
Serve a whole fleet: the gateway
The MCP bridge and the direct client both REACH devices. A gateway goes the other way: it re-serves a fleet of Things onto a middleware, so any consumer on that bus drives every device through one protocol, with no client per device. One process fronts the fleet.
from thingctx import ThingClient
from thingctx.gateways import Gateway
from thingctx.gateways.builtin.mqtt import MqttGatewayBinding
client = ThingClient(tds=[...], bindings=[...]) # reach the devices
gateway = Gateway(client, MqttGatewayBinding("broker:1883")) # serve them on the bus
await gateway.start()
A request arrives on a topic, the gateway resolves it against the native device,
and the reply goes back on the bus. The engine names only the five WoT operations;
the driver owns the wire. If the client carries the authorization seam
(pdp/identity), the same per-operation check runs for a bus request, so the
gateway is not a bypass. MQTT and MCP ship as drivers; a new middleware is one
GatewayBinding class and the engine never changes. See
docs/SECURITY.md.
Where MCP fits
MCP is a way to deliver tools to an agent you cannot hand them to directly. It is a delivery channel, not the integration itself. The integration, what a system does, how to reach it, who may call it, is the description. thingctx reads that description and drives the system client-side; when an agent is closed and only takes tools through MCP, the same description is delivered over the bridge (see above). The document is the durable part; the channel is the agent vendor's choice, and it can change without touching a Thing Description.
To expose a system over MCP you write a server, deploy it, and keep it running,
one per integration. N systems means N processes to operate. A Thing Description
is a static file: write it, or generate one from an existing spec
(thingctx import openapi <spec> compiles an OpenAPI file or URL into a TD), then
check it into git or serve it from a URL. There is nothing to run, nothing to keep
alive. Integration becomes data, not a service, and data scales to a fleet for free.
A messy device (binary protocol, a session dance) gets one thin connector that exposes a clean WoT face; the TD describes that. The connector is consumed the same way by an LLM, an MCP client, or anything else.
The table below measures one axis: what you build and operate per integration, not what each approach can do. thingctx wins on operational weight and cold-start; it says nothing about auth depth, policy, or ecosystem, which are their own questions.
See examples/01_mcp_baseline.py (a server per
integration) and
examples/02_thingctx_baseline.py (no
server). Both drive the same pump; every result is asserted equal to calling
the system directly.
| per integration | MCP (stdio) | MCP (http) | thingctx |
|---|---|---|---|
| server process | per session | 1, long-run | 0 |
| hand-written lines | 142 | 142 | 10 |
| time to first call | 540 ms | 13 ms | 2 ms |
thingctx calls in milliseconds because there is no server to start. MCP needs
one, and the transport sets the cost: stdio spawns it per session (the first
call pays process startup, 540 ms), streamable-HTTP is a server you keep running
and connect to (13 ms, warm). Once connected, per-call latency is small for all
three; the difference is the server you build and run to get there. The Thing
Description is data, about 145 lines of JSON, written once and read by every
consumer. Reproduce with python examples/_measure.py.
Interoperability
thingctx consumes a Thing Description no matter who wrote it, including TDs emitted
by standards-compliant producers, not just hand-written ones. Two demos under
examples/interop/ prove it end to end:
- node-wot: the
W3C WoT reference implementation
exposes a
counterThing; thingctx fetches its served TD and drives it (read, increment, read) with no node-wot client in the loop. - Eclipse Ditto: Ditto generates a TD for a digital twin; thingctx consumes it and round-trips twin state straight through Ditto's API.
Same consumer, different producers, zero glue: any conformant TD producer → thingctx.
And UTCP
UTCP shares thingctx's thesis: the integration is a description the client reads, not a server you operate. The difference is the description. UTCP defines its own manual format and ships SDKs in several languages today. thingctx builds on the ratified W3C Web of Things Thing Description instead, which buys four things a bespoke manual does not:
- One format for devices and APIs. A TD describes a REST endpoint, an MQTT topic, an SSE event stream, or a piece of hardware in the same document, so an agent reaches an industrial gateway and a SaaS API through one interface.
- Discovery built in. The WoT Thing Description Directory is a standard for serving and searching a whole fleet of Things; thingctx reads any compliant TDD.
- Vendor-neutral and stable. It is a W3C Recommendation, not a single project's schema, so a TD you write is portable across consumers.
- Built for device interaction patterns. A TD models properties, actions, and events as first-class affordances, so a consumer can observe a property or subscribe to a stream of readings straight from the description. UTCP's manual centers on describing callable tools; event subscription is not part of what it defines.
The trade-off: UTCP's manual is lighter to hand-write and UTCP ships more language clients today. thingctx bets that a ratified standard, read the same way by an LLM, an MCP client, and a factory gateway, is worth that.
Reference
ThingClient: the core
Stdlib only, with no dependency on any agent framework. ThingClient has no
LLM and no opinion on what chose the action. It reads properties, writes them,
and streams events, and routes each call to the transport the TD's form names,
so one client can read over HTTP and subscribe over MQTT without you wiring
either:
await client.read_property("pump.rpm") # e.g. an HTTP GET
await client.write_property("pump.target_rpm", 1500)
async for evt in await client.subscribe("pump.overheat"): # e.g. an MQTT topic
... # evt is the payload, e.g. {"temp": 98}
(thingctx.from_url(...) returns a ready LLMHost if you just want a loop out
of the box.)
Registry
Where descriptions come from: a folder of files, a URL, or a Thing
Description Directory (TDD). ThingClient, the MCP bridge, and the LLM loop
all build from the same registry.
client = thingctx.ThingClient.from_registry(thingctx.from_arg("./examples/registry/"))
The hosted registry at thingctx.com is one such source.
The TDD is the W3C WoT Discovery
standard (a final Recommendation): a service that serves a whole fleet of
Things from a /things endpoint, with optional search. thingctx reads from any
compliant TDD. Point from_arg at its URL.
Authentication
The TD declares the scheme (bearer, basic, apikey); the secret is
supplied to the binding at runtime, never in the TD, so a TD is safe to commit
and share. Secrets are keyed by Thing id, then slug, then scheme name, so one
client can carry a different secret per Thing.
thingctx.HttpBinding(credentials={"weather": "secret"}) # by Thing id/slug, or scheme name
License
Apache-2.0. Copyright 2026 The thingctx Authors.
No comments yet
Be the first to share your take.