---
title: "Unify Your Plant-Floor Data with Claude Code and TimescaleDB"
published: 2026-05-28T06:00:00.000-04:00
updated: 2026-05-28T07:10:32.000-04:00
excerpt: "Learn how to use Claude Code Agent Teams and TimescaleDB to build a unified plant-floor data pipeline — one governed table, three protocols, zero middleware."
tags: IoT, TimescaleDB
authors: Damaso Sanoja
---

> **TimescaleDB is now Tiger Data.**

A typical plant floor speaks many protocols. Three of the most popular are Modbus/TCP, OPC UA, and MQTT. None of these define how a tag is named or how a timestamp is recorded. One source might stamp readings in local time; the next in UTC. One device uses `snake_case`, the other `kebab-case`. Each mismatch fragments the [Unified Namespace (UNS)](https://inductiveautomation.com/resources/article/uns-unified-namespace) you set out to build. Closing those gaps by hand is the usual answer.

This article walks through a working template that uses Claude Code [Agent Teams](https://code.claude.com/docs/en/agent-teams) to build one collection client per protocol, each writing directly into a single governed [TimescaleDB](https://github.com/timescale/timescaledb) table with UNS naming enforced as the data lands. The reasoning behind each piece is what lets you adapt it to your plant and your operators’ know-how.

## What a Unified Namespace Actually Costs

The namespace itself is the cheap part. _What costs you is closing the gap between many protocol-specific sources and one consistent dataset, and keeping it closed as the plant floor changes._

There are two conventional ways to close that gap. One is to buy it: a middleware or broker product that ingests every protocol, normalizes the names, and forwards a clean stream. It works, and it is another vendor in the stack, another license, another box to operate. The other is to do it by hand: engineers reconcile device configurations and tag-mapping files until the names line up. That works too, until someone adds a tag and the reconciliation starts over. _Neither option makes the consistency self-enforcing_. The naming contract lives in someone’s head or someone’s spreadsheet, and it drifts.

There is a third option: build the collection layer yourself, but implement it as a coordination problem rather than a coding marathon. One small client per protocol, each reading from its source and writing directly into a single governed TimescaleDB table, with the UNS naming contract enforced while the code is written instead of reconciled after. Claude Code Agent Teams make that practical: a Lead session and several teammate sessions that share a task list and a peer mailbox, enough to build multiple clients in parallel against one contract without the work drifting apart.

![Unify Your Plant-Floor Data with Claude Code and TimescaleDB](https://storage.ghost.io/c/6b/cb/6bcb39cf-9421-4bd1-9c9d-fa7b6755ba0e/content/images/2026/05/BUILDING-UNS-ADAPTERS---2.png)

The contract is the coordination layer. Agents enforce it; _they do not replace the engineering judgment that wrote it._

## Agent Team in Action: Three Clients, One Contract

In this section, the agent team builds three collection clients against one shared contract that holds them to the same output, reviews the resulting code, and verifies the readings they write to TimescaleDB. The [companion repository](https://github.com/timescale/uns_adapters_claude) has the scaffold, the prerequisites, and a one-command setup script if you want to follow along.

Five agents do the work:

| Agent | Type | Role |
| --- | --- | --- |
| modbus-client | Teammate | Poll a Modbus-TCP device and write readings to TimescaleDB |
| opcua-client | Teammate | Subscribe to an OPC UA server and write readings to TimescaleDB |
| mqtt-client | Teammate | Subscribe to an MQTT broker and write readings to TimescaleDB |
| contract-reviewer | Read-only reviewer | Static review of all three clients against CLAUDE.md |
| timescale-validator | Validator | End-to-end pipeline check against landed data |

The three client teammates are not equal in design. `modbus-client` and `opcua-client` poll their sources on an interval; `mqtt-client` consumes a live broker stream, where [batching and back-pressure](https://www.tigerdata.com/blog/mqtt-sql-practical-guide-sensor-data-ingestion) genuinely matter.

On disk, the whole team is a handful of Markdown files and a few scripts:

```markdown
├── CLAUDE.md                          # The UNS data contract (binding for all clients)
├── .claude/
│   ├── settings.json                  # Agent Teams enabled + teammate permissions
│   ├── agents/
│   │   ├── modbus-client.md            # Modbus-TCP client teammate
│   │   ├── opcua-client.md             # OPC UA client teammate
│   │   ├── mqtt-client.md              # MQTT client teammate
│   │   ├── contract-reviewer.md        # Cross-client reviewer definition
│   │   └── timescale-validator.md      # End-to-end pipeline + DB validation
│   ├── rules/
│   │   ├── shared-code.md             # Import from shared/, never redefine
│   │   ├── file-ownership.md          # Which agent writes where
│   │   ├── python-conventions.md      # uv, naming, imports
│   │   └── definition-of-done.md      # Client completion criteria
│   └── skills/
│       ├── run-tests/SKILL.md         # Run client unit tests
│       ├── validate-contract/SKILL.md # Static review against CLAUDE.md
│       └── validate-pipeline/SKILL.md # End-to-end pipeline validation
```

There is no orchestrator agent here. Your main Claude Code session plays the Lead, and you are the audit point that closes the loop.

Two walkthroughs follow: a happy path through clean code and clean data, then a deliberately broken run that shows what AI reasoning under ambiguity looks like in practice.

### The happy path

When you kick off the team, three teammates start working in parallel. Each has its own scope (`clients/modbus/`, `clients/opcua/`, `clients/mqtt/`), reads the contract on startup, and stays out of the others’ files.

![](https://storage.ghost.io/c/6b/cb/6bcb39cf-9421-4bd1-9c9d-fa7b6755ba0e/content/images/2026/05/unify-plant-floor-data-with-claude-code-timescaledb-1.png)

Once the three clients are built, the pipeline runs through two validation layers. First, **code review**: the `contract-reviewer` reads each client’s source against the UNS data contract, flagging naming violations, hierarchy mismatches, and timestamp handling that doesn’t match it. It is read-only; it judges, it doesn’t fix. Then, **data review**: the `timescale-validator` runs the deterministic `validate-pipeline` skill, six checks against the readings that landed in TimescaleDB, and interprets the result.

Two layers, two intentionally separate concerns: **the reviewer catches what the code says, the validator catches what the data does**.

![](https://storage.ghost.io/c/6b/cb/6bcb39cf-9421-4bd1-9c9d-fa7b6755ba0e/content/images/2026/05/unify-plant-floor-data-with-claude-code-timescaledb-2.png)

To verify results, open the Tiger Cloud SQL editor and ask the namespace itself:

```SQL
-- Full UNS namespace: six-level ISA-95 hierarchy materialized in Postgres
SELECT enterprise, site, area, line, cell, tag_name, uns_path
FROM uns_namespace
ORDER BY uns_path;
```

Every reading the three clients wrote lands keyed to a row here: one namespace, assembled from three protocols.

![](https://storage.ghost.io/c/6b/cb/6bcb39cf-9421-4bd1-9c9d-fa7b6755ba0e/content/images/2026/05/unify-plant-floor-data-with-claude-code-timescaledb-3.png)

That table, queryable as a single object alongside `tag_history`, is the payoff.

### The failure path

Six checks passing on a fresh run is a useful sanity test, not a demonstration that the framework has teeth. Stay in the same session, open the Tiger Cloud SQL editor, and break something downstream that your agents can’t see:

```SQL
-- Shift area_02 timestamps back 5 hours
-- (simulates naive local time written as UTC)
UPDATE tag_history
SET ts = ts - INTERVAL '5 hours'
WHERE tag_id IN (
    SELECT id FROM uns_namespace WHERE area = 'area_02'
);
```

That `UPDATE` simulates one of the most common failure modes on a real plant floor: a single source quietly stamping local time as UTC, exactly the case a UNS validator should not just _catch_ but _explain_. Re-run `/validate-pipeline` in the main session. The deterministic half does its job: cross-area drift fires, `area_02` lands five hours behind `area_01`.

![](https://storage.ghost.io/c/6b/cb/6bcb39cf-9421-4bd1-9c9d-fa7b6755ba0e/content/images/2026/05/unify-plant-floor-data-with-claude-code-timescaledb-4.png)

Interpretation is the other half. From where the Lead stands, the picture looks tame: code review passed minutes ago, configurations are unchanged, only `area_02` readings look shifted. Your agents only know what their prompts taught them. The excerpts below are verbatim from our Lead run; your run will read differently, but the gap in the reasoning will be the same.

_“The earlier timescale-validator report was wrong. It claimed”all 6 checks PASS. cross-area drift PASS, aligned within ~1s.”_

The Lead then goes looking for the real cause:

_“The pipeline itself is actually sound. I just ran all three clients fresh against the live simulator: every cell - including both area\_02 cells - Landed at correct, aligned UTC (15:14). The OPC UA client log confirms it correctly handles SourceTimestamp=None by falling back to ServerTimestamp (timezone-aware UTC); Modbus stamps datetime.now(timezone.utc); MQTT decodes ts\_ms with tz=timezone.utc. The code is git-pristine and \[…\]”_

Read that diagnosis carefully. It is internally consistent. The code passes review, the bug looks historical, the explanation closes. **It is also wrong**. Look at what is missing from the diagnosis: any check against the database itself. We edited the data directly, the client code is fine, and the actual cause is an UPDATE the Lead never queried for.

That gap is the lesson. The Lead didn’t query `tag_history.ts` against the wall clock or check for recent writes against the `tag_history` table. **It took the path of least resistance through an under-specified prompt: code passed, the simulator looked clean, the failure must be historical.** A plant engineer who’d been burned once by an out-of-band schema change would add a single behavior rule to the validator’s prompt: _“When a deterministic check fails, query `tag_history.ts` against wall clock and review recent writes before concluding cause.”_ That one line, encoded in the agent’s prompt, would have caught what this run missed.

## Where You Inject Know-How

Where does that prompt-level know-how actually live? Two seams hold it, and you've already seen both in the team's file tree: a shared layer that every agent inherits on startup, and a per-agent layer where each worker's scope and behavior live.

### Shared layer: the contract and rules

Every agent reads `CLAUDE.md` and the files in `.claude/rules/` before doing anything else. Anything you put there applies to the whole team, every session, and survives when the team turns over.

`CLAUDE.md` carries the contract: ISA-95 hierarchy, naming rules, timestamp handling, and the table schema each client writes to. A short excerpt from the timestamp section:

```markdown
1.All timestamps MUST be stored as UTC. `tag_history.ts` is TIMESTAMPTZ;
   write timezone-aware values only.
2.If the source provides naive timestamps (no timezone info), the client MUST:
   -Convert using `source_timezone` from `config/{client}.yaml`
   -Log a warning: "Naive timestamp from {source}, assuming {timezone}"
   -Never silently assume UTC.
3.Clients that stamp at read time must document the clock source.
```

That’s the template. What goes here from your side is everything the contract doesn’t yet anticipate: allow-listed legacy tag formats, per-area timezone overrides, a sensor model that reports values in centi-units instead of base units. Each one is a paragraph away from being enforced across every client.

The other half of the shared layer is `.claude/rules/:` separate files that apply with the same weight as `CLAUDE.md`. The split is about organization, not priority. In this tutorial we used rules for team-wide conventions, like `definition-of-done.md` (when a client is complete: contract passes, tests pass, no hardcoded values), `file-ownership.md` (which keeps two client teammates from corrupting shared code), plus Python and shared-import conventions. On a more complex plant floor, this same folder is where you would push per-area or per-sensor specifics to keep `CLAUDE.md` itself lean. Add a rule when you find yourself reminding every engineer of the same thing every time.

### Per-agent layer: roles and tools

The shared layer covers what’s universal. The agent layer is where each agent diverges: its scope (which files it may write to), its behavior rules (how it reasons through its task), and the deterministic skill it invokes first. Here’s the behavior section of `timescale-validator.md`:

```markdown
## Behavior Rules

-Run the validation first. Interpret, don't repeat raw output.
-Ground every insight in data: query results, row counts, timestamps.
-When diagnosing a failure, cross-reference client code and config.
-Distinguish testing/prototyping-scope findings from production recommendations.
-When done, message the Lead with the full validation report.
```

That bullet list is where the failure path most directly cashes in. The rule a plant engineer would have added, _"When a deterministic check fails, query `tag_history.ts` against wall clock and review recent writes before concluding cause,"_ has more than one home, and the choice is yours:

-   **As a shared rule** in `CLAUDE.md` or `.claude/rules/`, when the diagnosing session is your main lead, and you want every agent to inherit it on startup.
-   **As a behavior bullet**, a sixth line in `timescale-validator.md`, when you delegate diagnosis to that one agent.
-   **As deterministic code**, a check in the `validate-pipeline` skill that flags any area whose latest timestamp lags the others by a suspicious round-number offset, when you'd rather the script catch the pattern than trust an agent to remember.

Same know-how, three encodings: prose every agent reads, prose one agent reads, or Python nobody has to remember. Pick whichever your team will keep maintained.

That third option is the hybrid principle paying off: **determinism, where you can, interpretation where you can't**. Skills carry the deterministic half; Python, an agent invokes rather than reasons through. The pipeline's validation logic lives in `scripts/validate_pipeline.py`; `validate-pipeline` is the prompt-level wrapper that invokes it. We keep Python in `scripts/` rather than bundling it in the skill folder, but both approaches work; pick whichever you prefer.

This is _our_ opinionated cut of the framework, not a prescription. Swap, narrow, or expand any of the primitives. There is no one-size-fits-all UNS pipeline because there is no one-size-fits-all factory.

## From Clients to Governed Data

The Agent Teams produce three clients that write straight into TimescaleDB. No broker in the middle, no ingestion service to operate, no second copy of the data to keep in sync. The namespace and the readings sit together in one Postgres instance, which is [what turns contextual questions, and AI-agent queries, into a single JOIN](https://www.tigerdata.com/blog/ask-factory-floor-anything-structuring-industrial-data-ai-agents) instead of a cross-system integration project. It is also what lets the data validator close the loop on the code reviewer.

That same architecture is a starting point, not an endpoint. Scheduled drift reviews, on-demand questions against the namespace, real-time anomaly checks: each one would be a different shape of the team you just ran, written by the engineers who already know which drift matters and which anomaly is noise. One-shot build today, 24/7 operational layer next.

Clone the repo, point it at a Tiger Cloud instance, and start replacing the slim agent prompts with the bug your team learned from last quarter. The template is the floor. The ceiling is the know-how your engineers already carry.