Install Hexabot CLI and Create Your First AI Workflow

Install Hexabot CLI and Create Your First AI Workflow Project

Getting started with a new automation platform should not take hours.

In this tutorial, I’ll show you how to install the Hexabot CLI, create a new project from the starter template, and run it locally.

This is the fastest way to start experimenting with Hexabot and build your first AI workflow project.

Watch the full video here:

What is Hexabot?

Hexabot is an AI workflow automation platform designed for building workflows, actions, agents, and conversational channels in one runtime.

You can use it to create automations that connect conversations to real actions, integrate with external services, and build AI-powered assistants that are more controlled, observable, and extensible.

Instead of starting from a large monorepo or manually wiring everything yourself, the Hexabot CLI helps you generate a clean project workspace and start building faster.

Prerequisites

Before installing Hexabot, make sure you have:

  • Node.js installed
  • npm or another package manager
  • A terminal
  • Docker, if you want to run Docker-based services locally

If you do not have Node.js installed yet, I recommend using NVM so you can easily manage Node.js versions.

Node.js with NVM guide:
https://docs.hexabot.ai/developer-guide/setup-node.js-with-nvm

You can quickly check your local setup with:

node --version
npm --version

1. Install the Hexabot CLI

Install the Hexabot CLI globally:

npm install -g @hexabot-ai/cli

Once installed, check that the CLI is available:

hexabot --help

You can also use the CLI without installing it globally:

npx @hexabot-ai/cli --help

2. Create a New Hexabot Project

Now create your first project:

hexabot create my-project

The CLI will scaffold a new Hexabot project and guide you through the initial setup.

You may be asked to provide initial admin account details such as:

  • First name
  • Last name
  • Email
  • Password

These credentials will be used to access your local Hexabot admin interface.

3. Move Into the Project Folder

Once the project is created, move into the new workspace:

cd my-project

At this point, you have a generated Hexabot project ready to run locally.

4. Start the Local Development Server

Run the project with:

hexabot dev

This starts your local Hexabot development environment.

Once the app is running, you can open the admin interface in your browser:

http://localhost:3000

You may also find the local API and API documentation at:

http://localhost:3000/api
http://localhost:3000/docs

Useful CLI Commands

Here are a few commands you will probably use often:

hexabot create <project-name>

Create a new Hexabot project.

hexabot dev

Run the project locally in development mode.

hexabot check

Check your local project setup.

hexabot migrate

Run database migrations when needed.

For the full CLI reference, check the official documentation:

https://docs.hexabot.ai/developer-guide/cli-command-reference

Common Issues

hexabot: command not found

This usually means the CLI was not installed globally or your npm global path is not available in your terminal.

You can try:

npx @hexabot-ai/cli --help

Or reinstall the CLI:

npm install -g @hexabot-ai/cli

Wrong Node.js version

Use NVM to install and switch Node.js versions:

nvm install 20
nvm use 20

Then check:

node --version

Port already in use

If the app cannot start because a port is already being used, stop the process using that port or update your local configuration.

What to Do Next

Once your project is running, the next step is to create your first workflow.

You can start here:

https://docs.hexabot.ai/quickstart/create-your-1st-workflow

You can also explore the installation guide:

https://docs.hexabot.ai/quickstart/installation

Useful Links

Hexabot Website:
https://hexabot.ai

Documentation:
https://docs.hexabot.ai

Installation Guide:
https://docs.hexabot.ai/quickstart/installation

Setup Node.js with NVM:
https://docs.hexabot.ai/developer-guide/setup-node.js-with-nvm

CLI Command Reference:
https://docs.hexabot.ai/developer-guide/cli-command-reference

Create Your First Workflow:
https://docs.hexabot.ai/quickstart/create-your-1st-workflow

GitHub Repository:
https://github.com/hexabot-ai/hexabot

Community Forum:
https://community.hexabot.ai

YouTube Channel:
https://www.youtube.com/@hexabot-videos

Final Thoughts

The Hexabot CLI is the easiest way to start building with Hexabot.

In just a few commands, you can install the CLI, generate a new project, run it locally, and start creating AI workflows.

If you find Hexabot useful, consider giving the repository a star on GitHub:

 
 Star the Hexabot Github Repository ⭐
 

It helps support the project and makes it easier for more developers to discover it.

Building Automated AWS Permission Testing Infrastructure for CI/CD

Permission testing CI/CD architecture: GitHub Pull Request triggers a GitHub Actions CI/CD Workflow, which fans out to S3 Tests, Glue Tests, and Athena Tests in parallel, with all results aggregated into a Test Report posted back to the PR

Originally published on graycloudarch.com.

I deployed a permission set for our data engineers five times before it worked correctly.

The first deployment: S3 reads worked, Glue Data Catalog reads worked. Athena queries failed — the query engine needs KMS decrypt through a service principal, and I’d missed the kms:ViaService condition. Second deployment: Athena worked. EMR Serverless job submission failed — missing iam:PassRole. Third deployment: EMR submission worked. Job execution failed — missing permissions on the EMR Serverless execution role boundary. I kept deploying, engineers kept getting blocked, I kept opening tickets.

Five iterations. Two weeks. Every failure meant a data engineer opened a ticket instead of running their job.

The problem wasn’t that IAM is complicated — it is, but that’s expected. The problem was that I had no way to catch these issues before deploying to the account where real engineers were trying to do real work. Every bug was a production bug.

The “Access Denied” Debugging Loop

Here’s what the reactive debugging cycle looks like from the inside.

Engineer opens a ticket: AccessDeniedException: User is not authorized to perform: s3:GetObject. I add s3:GetObject to the permission set. Next day: AccessDeniedException: s3:PutObject. I add s3:PutObject. Day after: write succeeds but cleanup fails — s3:DeleteObject. At this point I’ve done four deployment cycles and two days of work to get S3 read/write/delete working. If I’d just added s3:* I’d be done, but that violates least-privilege and opens the raw zone to write access, which we explicitly don’t want.

The deeper issue is that individual services don’t fail atomically. Athena requires athena:StartQueryExecution and athena:GetQueryResults and athena:GetQueryExecution, but it also requires KMS decrypt through the Athena service principal to read encrypted S3 results. That last piece isn’t in the Athena docs — you find it by failing in production.

I wanted a way to find it before deploying.

What I Built

The testing framework has four components: per-persona permission set templates, a Bash test library, per-service test scripts, and a GitHub Actions workflow that runs everything on pull requests.

Permission testing CI/CD architecture: GitHub Pull Request triggers a GitHub Actions CI/CD Workflow, which fans out to S3 Tests, Glue Tests, and Athena Tests in parallel, with all results aggregated into a Test Report posted back to the PR

The workflow triggers on any pull request that modifies the identity-center Terraform directory. Tests run against real AWS accounts — dev and nonprod — using test credentials provisioned for that purpose. Results post as a PR comment before anyone approves the change.

Phase 1: Pre-Validated Templates

Before I wrote a single test, I needed a starting point for permission sets that captured the patterns I’d learned the hard way. Templates that handle the non-obvious pieces — zone-scoped S3 access, KMS conditions tied to specific services, explicit denies for destructive operations.

The AnalystAccess template is representative. Analysts get read-only access to the curated zone of the data lake, Athena query execution in the primary workgroup, and KMS decrypt — but only when the decrypt request originates from S3 or Athena, not from arbitrary API calls:

inline_policy = jsonencode({
  Version = "2012-10-17"
  Statement = [
    {
      Sid    = "GlueCatalogReadOnly"
      Effect = "Allow"
      Action = ["glue:GetDatabase", "glue:GetTable", "glue:GetPartitions", "glue:SearchTables"]
      Resource = [
        "arn:aws:glue:*:*:catalog",
        "arn:aws:glue:*:*:database/curated_*",
        "arn:aws:glue:*:*:table/curated_*/*"
      ]
    },
    {
      Sid    = "S3CuratedReadOnly"
      Effect = "Allow"
      Action = ["s3:GetObject", "s3:ListBucket"]
      Resource = ["arn:aws:s3:::lake-bucket-*/curated/*", "arn:aws:s3:::lake-bucket-*"]
      Condition = { StringLike = { "s3:prefix" = ["curated/*"] } }
    },
    {
      Sid    = "AthenaQueryExecution"
      Effect = "Allow"
      Action = ["athena:StartQueryExecution", "athena:GetQueryExecution", "athena:GetQueryResults", "athena:StopQueryExecution"]
      Resource = "arn:aws:athena:*:*:workgroup/primary"
    },
    {
      Sid    = "KMSDecryptViaSvc"
      Effect = "Allow"
      Action = ["kms:Decrypt", "kms:DescribeKey"]
      Resource = "arn:aws:kms:*:*:key/*"
      Condition = {
        StringEquals = { "kms:ViaService" = ["s3.us-east-1.amazonaws.com", "athena.us-east-1.amazonaws.com"] }
      }
    },
    {
      Sid    = "DenyDestructiveOps"
      Effect = "Deny"
      Action = ["s3:DeleteObject", "s3:DeleteBucket", "glue:DeleteDatabase", "glue:DeleteTable"]
      Resource = "*"
    }
  ]
})

The kms:ViaService condition is the piece that took five production failures to discover. KMS decrypt without that condition allows an analyst to call kms:Decrypt directly from their shell, which is not what we want. The condition locks decrypt to requests that pass through S3 or Athena specifically.

The explicit deny block matters too. Without it, if someone later grants broader S3 permissions to this persona for a different reason, the curated zone protection evaporates. The deny creates a hard floor regardless of what else gets added.

Phase 2: The Test Framework

I chose Bash over Python or a proper test framework deliberately. The tests run in CI with no dependencies beyond the AWS CLI — no package installs, no virtual environments, no version pinning of test libraries. The machines running these tests already have the AWS CLI.

The core library in lib/test-framework.sh:

declare -a TESTS_PASSED=()
declare -a TESTS_FAILED=()

run_test() {
  local test_name="$1"
  local test_command="$2"
  local description="$3"

  if eval "$test_command" &>/dev/null; then
    TESTS_PASSED+=("$test_name")
    echo "  ✅ PASS: $test_name"
  else
    TESTS_FAILED+=("$test_name")
    echo "  ❌ FAIL: $test_name"
  fi
}

generate_text_report() {
  echo "Total: $((${#TESTS_PASSED[@]} + ${#TESTS_FAILED[@]}))"
  echo "Passed: ${#TESTS_PASSED[@]}"
  echo "Failed: ${#TESTS_FAILED[@]}"
  [ ${#TESTS_FAILED[@]} -gt 0 ] && printf '  - %sn' "${TESTS_FAILED[@]}"
}

The most important design decision in the test scripts is testing denials as carefully as allowances. Testing only what should succeed tells you the permission set isn’t obviously broken. Testing what should fail tells you it’s not accidentally too permissive.

# Test what should succeed
run_test "s3-list-curated" 
  "aws s3 ls s3://lake-bucket-dev/curated/" 
  "Analyst can list curated zone"

# Test what should fail (negative test)
run_test "s3-write-denied" 
  "! aws s3 cp /tmp/test.txt s3://lake-bucket-dev/curated/test.txt 2>&1 | grep -q 'AccessDenied'" 
  "Analyst cannot write to curated zone"

run_test "s3-raw-zone-denied" 
  "! aws s3 ls s3://lake-bucket-dev/raw/ 2>&1 | grep -q 'AccessDenied'" 
  "Analyst cannot access raw zone"

Beyond service-level tests, I run persona tests that simulate end-to-end workflows. An analyst’s workflow isn’t “call S3, then call Athena separately” — it’s “run an Athena query that reads encrypted S3 data and writes results to the query results bucket.” That integration test catches failures that individual service tests miss. The original five-iteration DataPlatformAccess failure? An individual S3 test would have passed. A persona test running an actual Athena query against the encrypted lake would have caught the KMS gap.

Phase 3: CI/CD Integration

The GitHub Actions workflow triggers on pull requests that touch the identity-center Terraform directory, runs tests in a matrix against dev and nonprod, and posts a summary comment to the PR.

on:
  pull_request:
    paths:
      - 'common/modules/identity-center/**/*.tf'

permissions:
  contents: read
  id-token: write
  pull-requests: write

jobs:
  test-permissions:
    strategy:
      matrix:
        environment: [workloads-dev, workloads-nonprod]
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::${{ matrix.environment.account }}:role/github-actions-role
      - run: ./scripts/test-permissions/run-permission-tests.sh --persona analyst

The id-token: write permission is required for OIDC authentication to AWS — the workflow assumes a role in each account rather than using long-lived credentials in GitHub Secrets. This is the right pattern: credentials rotate automatically, and there’s no secret to rotate manually or accidentally expose.

The PR comment posts the full test output with pass/fail counts per persona per account. A reviewer can look at the comment and immediately see whether the permission change has test coverage and whether the tests pass.

Three Things I Learned the Hard Way

First: test KMS decryption through each service separately. kms:Decrypt via S3 and kms:Decrypt via Athena are different IAM evaluation paths even though they’re the same API call. A test that puts an object and gets it back via S3 directly won’t catch a broken Athena KMS path.

Second: negative tests matter as much as positive ones. Before I had the test framework, every permission set I wrote was tested only for what it should allow. I had no systematic check that it didn’t allow more. The denial tests are what give security reviewers confidence.

Third: persona tests catch failures that service tests miss. Individual service tests are fast to write and good for regression coverage, but they test permissions in isolation. Real workflows cross service boundaries. Build both.

What Changed

Before the framework: five iterations to get one permission set right, every iteration a production impact. After: 95% of permission issues caught at PR review time. Zero production impacts from permission bugs since we shipped it. The templates reduced new permission set creation time by about 70% — instead of starting from scratch with the IAM documentation, we start from a pre-validated base and modify from there.

The time investment was about a week: two days for templates, two days for the test framework and scripts, one day for CI/CD integration and documentation. That investment paid back in the first sprint when the analyst permission set for a new hire went out correct on the first deployment.

Running into IAM permission debugging loops on your team? Reach out — permission testing infrastructure is one of the first things I build when joining a new platform team.

Why Your AI Initiatives Fail Without a Semantic Layer

AI with vs without a semantic layer — failure modes and fixes

AI with vs without a semantic layer — failure modes and fixes

Your team builds an AI agent. It connects to your data warehouse. A product manager types “What was revenue last quarter?” and gets a number. The number is wrong. Nobody knows it’s wrong until Finance runs the same query manually and gets a different result.

This happens constantly. And the problem isn’t the AI model. It’s the missing layer between the model and your data.

The Promise vs. the Reality

Natural language analytics is the most requested feature in every data platform survey. Business users want to ask questions in plain English and get accurate answers. No SQL. No tickets. No waiting.

The technology exists. Large language models can generate SQL from natural language with impressive accuracy. But accuracy on syntax isn’t accuracy on meaning. An LLM can write grammatically correct SQL that returns the wrong answer because it doesn’t understand your business definitions.

A semantic layer provides those definitions. Without one, AI analytics is a demonstration that works in a meeting but fails in production.

Five Ways AI Goes Wrong Without a Semantic Layer

AI agent confused by raw data — hallucinating metrics and joins

Metric Hallucination

Your LLM decides that Revenue = SUM(amount) from the transactions table. But your actual Revenue formula is SUM(order_total) WHERE status = 'completed' AND refunded = FALSE from the orders table. The AI’s number is plausible. It’s also wrong by 15%. Nobody catches it because it looks reasonable.

The fix: Canonical metric definitions in virtual datasets. The AI references the view, not its own invented formula.

Join Confusion

There are three paths from orders to customers: via customer_id, via billing_address_id, and via shipping_address_id. For revenue analysis, you want customer_id. The LLM picks billing_address_id because it seems logical. The numbers are close enough that the mistake survives review.

The fix: Pre-defined join relationships in the semantic model. The AI follows the approved path.

Column Misinterpretation

A column called date appears in the orders table. Is it the order date, ship date, or invoice date? The LLM guesses order date. It’s actually the ship date. Every time-based query is off by 2-5 days.

The fix: Wiki descriptions on every column. The semantic layer tells the AI that date is ShipDate and OrderDate is the field to use for time-based revenue analysis.

Security Bypass

Your BI dashboard applies row-level security so regional managers only see their region’s data. The AI agent queries the raw table directly, bypassing the BI layer. A regional manager asks about “their” revenue and sees the entire company’s numbers.

The fix: Fine-Grained Access Control enforced at the semantic layer. The AI queries views, not raw tables. Security policies travel with the data regardless of the access path.

Inconsistent Results

The same question asked twice generates different SQL because the LLM’s output is probabilistic. Monday’s answer: $4.2M. Wednesday’s answer: $4.5M. Both are “correct” given the SQL generated. Neither matches Finance’s number.

The fix: Deterministic definitions in the semantic layer. The same question always resolves to the same view, the same formula, the same result.

How a Semantic Layer Grounds AI

AI agent successfully using a semantic layer to produce accurate results

Each failure maps to a specific semantic layer component:

Each failure maps to a specific semantic layer component

This is why platforms that take AI analytics seriously embed the semantic layer directly into the query engine. Dremio’s approach combines virtual datasets, Wikis, Labels, and Fine-Grained Access Control into a single layer that both humans and AI agents consume. The AI doesn’t just generate SQL. It consults the semantic layer to understand what the data means, which formulas to use, and what the querying user is allowed to see.

What AI-Ready Architecture Looks Like

An AI-ready data platform doesn’t just connect an LLM to a database. It puts a structured context layer in between:

  1. Semantic layer defines metrics, documents columns, and enforces security
  2. AI agent reads the semantic layer to understand business context
  3. Query engine executes the AI-generated SQL with full optimization (caching, reflections, pushdowns)
  4. Results are returned in business terms through the same interface humans use

Without step 1, the AI is just a SQL autocomplete tool with no business understanding. It writes syntactically valid queries that produce semantically wrong answers. The semantic layer is the difference between a toy demo and a production-grade AI analytics system.

What to Do Next

If your AI analytics initiative is producing unreliable results, don’t upgrade the model. Audit the context the model has access to. Can it read your metric definitions? Column descriptions? Security policies? If the answer is no, the fix isn’t a better LLM. It’s a semantic layer.

Try Dremio Cloud free for 30 days

Giving AI Agents Eyes (Part 2): From Page Snapshots to Interaction Traces

In Part 1, we solved the representation problem: how to give an LLM a compact, semantic view of a web page using accessibility trees. That gave our AI agent the ability to answer “what is on this page?”

But users don’t ask that. They ask “what did I just click?” and “what was in that popup I closed?” Here’s the gap, concretely:

T=0s    User clicks "Blue Widget" in a data table
T=1s    Popup appears with item details (price, status, GTIN)
T=3s    User closes popup
T=5s    User opens AI assistant, asks "what's wrong with this item?"
T=5s    Assistant captures page state: sees 25 items in table, no popup
T=5s    Assistant has zero context about which item or what was in the popup

A page snapshot is a photograph. The user is asking about a video. The agent needs two things it doesn’t have: (1) a log of recent interactions (the user clicked “Blue Widget”), and (2) snapshots of ephemeral UI that no longer exists (the popup that showed price $24.99 and status Published).

Session replay tools (rrweb, FullStory, LogRocket) solve a related problem, but they produce DOM serialization data designed for visual playback — not semantic descriptions for LLM consumption. You need ~200 tokens of natural language, not 200 KB of mutation records.

This post covers how we built a user-activity tracker from inside a Module Federation remote that doesn’t own the host page, three bugs that compounded into a “one behind” symptom, and how we serialize 20 interaction events into a few hundred tokens of LLM-friendly text.

Strategy Pattern for Extensible Event Capture

Click events are one source — but probably not the only one we’ll ever want. The host app might later emit structured events via a PubSub system; a future host could fire shell-level navigation events that we’d want to record too. We didn’t want to rewrite the tracker the day either of those landed.

                     ActivitySource (interface)
                    /         |          
                   /          |           
        DomActivitySource  HostEventSource  HybridSource
        (capture-phase       (PubSub from    (both, with
         click listener)      host app)       priority rules)
                             |          /
                             |         /
                     useActivityTracker (hook)
                             |
                     Feature flag selects source at runtime:
                     "dom" | "host" | "hybrid" | "off"

The interface is minimal:

interface ActivitySource {
  start(): void;
  stop(): void;
  getTrace(): UserActivityTrace;
  clear(): void;
  onEvent?: () => void;  // callback when a new event is captured
}

Sources are plain classes; the React layer is one thin hook (useActivityTracker) that owns construction and cleanup. Hooks would have worked too — this isn’t a religious choice. We went with classes for three pragmatic reasons:

  1. Composability without React. HybridSource creates a DomActivitySource and HostEventSource internally and merges their outputs. Doable with hooks, but composition requires wrapper components or hook-forwarding patterns; instantiating two class instances and merging is simpler.

  2. Testability without rendering. You can instantiate a DomActivitySource in a unit test with jsdom, call start(), simulate clicks, and assert on getTrace() — no renderHook, no act(), no React tree.

  3. Future-proofing for non-React consumers. If the tracker ever needs to run in a worker, a vanilla shell, or a non-React MFE, a plain class moves over unchanged.

Capture-Phase Click Listeners

Our AI assistant is loaded into a large SaaS dashboard as a Module Federation remote. It’s not an iframe — it shares the same document as the host application. This is the architectural fact that makes activity tracking possible.

Because we share the DOM, a single listener captures every click on the host page:

document.addEventListener("click", this.handleClick.bind(this), { capture: true });

The capture phase ({ capture: true }) is important. It fires before the target element’s own click handlers, before any bubble-phase stopPropagation() calls in the host app can prevent us from seeing the event. (Caveat: a host-app listener installed earlier on document in the same phase that calls stopImmediatePropagation() will still hide events from us. We’ve never seen this in the wild — most apps only stopPropagation() from inside component handlers, which is bubble-phase — but it’s the one way the host can blind us if they want to.)

But we don’t want to log every click. A click on a wrapper div, a scroll container, or a decorative icon is noise. The extractClickDescriptor function walks up from the event target to find the nearest actionable element:

const ACTIONABLE_ROLES = new Set([
  "button", "link", "menuitem", "tab", "checkbox",
  "radio", "combobox", "textbox", "searchbox", "switch", "row",
]);

function findNearestInteractive(target: Element): Element | null {
  let el: Element | null = target;
  while (el) {
    // Skip our own UI
    if (el.matches('[data-assistant-container="true"]')) return null;
    // Respect opt-out attribute
    if (el.matches("[data-no-track]")) return null;

    const role = getRole(el);
    if (role && ACTIONABLE_ROLES.has(role)) return el;
    el = el.parentElement;
  }
  return null;
}

The walk-up pattern is essential because the actual click target is usually a child of the interactive element — a <span> inside a <button>, an <svg> inside a link. The allowlist focuses on ARIA roles that represent user-initiated actions, which eliminates the vast majority of noise.

Additional noise filters:

  • Debounce — Same element clicked within 300ms is suppressed (double-click)
  • Name required — Elements with no accessible name and no text content are ignored
  • Container guard — Clicks inside the AI panel itself are excluded

The result on a typical data-table page: roughly one actionable event per real click (occasionally two when a row + nested button both qualify), out of dozens of raw click events the page absorbs. No firehose.

Three Bugs That Compounded

The symptom looks like one bug. It’s three.

Your activity trace is always one interaction behind. The user clicks Item A, asks the assistant, and the assistant either sees nothing or sees the previous click. That’s the single symptom we chased.

What we actually had was three failure modes of the same underlying mismatch — synchronous DOM events meeting asynchronous React state. Each gave the bug somewhere to hide. Fixing any one of them in isolation didn’t move the needle.

The setup: The remote MFE captures DOM events synchronously. The captured data flows into React state, which flows through React Context to a consumer component in a different MFE (the AI chat UI), which reads the context when the user sends a message.

Bug 1: Conditional trace inclusion. Page-content extraction fires under three triggers — initial page load, significant DOM mutations, and route navigation. We initially only included the activity trace when the trigger was "message-send". The other triggers produced snapshots without traces. React deduplication kept the traceless version in state, so the chat UI read an empty trace at send time.

Fix: Always include the trace in every extraction, regardless of trigger.

Bug 2: No extraction on click events. Click events trigger none of the three extraction conditions above. The snapshot already in state has the trace from the last extraction — which captured clicks before the latest one.

Fix: Add an onEvent callback to the source interface. When a click is captured, fire the callback. In the page-content hook, the callback patches React state with the latest trace:

// In the page-content hook
const handleActivityEvent = useCallback(() => {
  const trace = getTraceRef.current();
  setSnapshot((prev) => {
    if (!prev) return prev;
    return { ...prev, userActivity: trace };
  });
}, []);
// In the DOM source's click handler
this.events.unshift(descriptor);
this.onEvent?.();  // patch React state immediately

Bug 3: Undeclared class property. The ActivitySource interface declares onEvent?: () => void, and the hook assigns it after construction:

source.onEvent = () => onEventRef.current?.();
source.start();

But the class never declared onEvent as a field:

// BROKEN — onEvent is not declared on the class
class DomActivitySource implements ActivitySource {
  private events: ActivityEvent[] = [];
  // ...
  handleClick(event: MouseEvent): void {
    // ...
    this.onEvent?.();  // always undefined
  }
}

In plain JS, source.onEvent = fn on an instance creates an own property that any later method call would see through this. We didn’t get that — somewhere in our toolchain (TypeScript with useDefineForClassFields, plus a class-fields transform in the build pipeline), the assignment landed somewhere the class methods didn’t read from. The exact mechanism was build-config specific, and chasing it stopped being interesting once we found the one-line fix:

// FIXED — explicit declaration
class DomActivitySource implements ActivitySource {
  onEvent?: () => void;    // this line fixes the bug
  private events: ActivityEvent[] = [];
  // ...
}

Declaring the field made initialization deterministic and the bug went away across every build target. What made it hard to find in the first place: handleClick works perfectly when you call it directly in a unit test. It only failed in production, where the method was bound and invoked by document.addEventListener.

General lesson: When you implement an interface with optional callback properties, declare them explicitly on the class. Don’t rely on whatever your transpiler does with externally-assigned-but-undeclared properties — the semantics are subtle enough that an explicit field is the safer default. One line of TypeScript is cheaper than three days of debugging.

Token-Efficient Activity Traces for LLMs

A raw JSON array of 20 events runs ~600 tokens once you account for repeated keys, escaped quotes, and structural noise. We compress it to ~300 by serializing to compact natural language:

RECENT USER ACTIONS (newest first):
- 14:32:05 click [button] "Edit Item" (in row: Blue Widget SKU-1234)
- 14:31:58 click [link] "Blue Widget" (in row: Blue Widget SKU-1234)
- 14:31:45 click [button] "Apply" (in toolbar: Filters)
- 14:31:30 click [checkbox] "Active" (in group: Lifecycle filter)

The non-obvious choice in this format is row context, not landmark context. For data-table apps, “in row: Blue Widget SKU-1234” is far more useful than “in region: main content.” The row context tells the LLM which data record the interaction was about. We extract it by walking up to the nearest <tr> or [role="row"] and joining the cell text with pipes (truncated to 60 chars). The LLM almost always asks about that one row — and now it knows which one.

The other choices are more obvious in hindsight: HH:MM:SS timestamps over ISO strings (session-relative time is enough, saves ~15 chars per line); [button] / [link] role brackets matching the same taxonomy the a11y tree uses; newest-first ordering so the most-likely-relevant action is at the top.

The buffer. 20 events in a circular ring, oldest evicted first, reset on page navigation. At a leisurely 1 click every 2-5 seconds, 20 covers ~40-100 seconds — enough for a complete workflow (filter, search, click item, browse popup, close popup). Real users sometimes burst faster than that; we cover that case in the limitations section.

Token cost. A typical event line tokenizes to ~15-25 tokens (timestamps and quoted names split poorly across tokenizers). A full 20-event buffer is ~300-500 tokens; popup snapshots add another ~200-500 each. Total overhead is roughly 8-15% on top of the existing page context payload — small per request, worth multiplying through your DAU × queries before calling it “negligible” at scale.

The format is injected into the LLM prompt with a framing instruction:

--- Recent User Activity ---
Use this to understand the user's intent and which item/element they are asking about.
<user_activity>
RECENT USER ACTIONS (newest first):
- 14:32:05 click [button] "Edit Item" (in row: Blue Widget SKU-1234)
...
</user_activity>

The framing line (“Use this to understand…”) matters more than its character count suggests. In our eval set, removing it noticeably increased the rate at which the model ignored the activity section entirely and answered the page-snapshot question instead of the per-user-action question. Treat the framing as load-bearing, not decorative.

The Full Pipeline

End to end, one click becomes one line in a prompt:

click → capture-phase listener → extractClickDescriptor() → ring buffer (20)
      → onEvent → setState → React Context → chat UI on send
      → POST with userActivity → backend NL serializer → <user_activity> tag in prompt

Each arrow above is either a synchronous in-process call or a single React render. The whole path runs in well under a frame; no network hops on the FE side.

Before vs After: What the Assistant Says

Back to the opening sequence — user clicks “Blue Widget”, a popup with item details appears, user closes it, then opens the assistant. Here’s what the LLM produces with and without the activity trace, given the same page state at message-send time (the table is on screen; the popup is gone):

Without activity trace:

User: what’s wrong with this item?

Assistant: I can see your catalog has 25 items. Could you tell me which item you’re asking about? You can click an item’s row to see details.

With activity trace (~300 extra tokens in the prompt):

User: what’s wrong with this item?

Assistant: You recently opened the details popup for Blue Widget (SKU-1234). Its current status is Suppressed and its price is $24.99. I can see the item’s basic fields, but the popup didn’t tell me why it’s suppressed — do you want me to check the listing-quality details, or is there a specific error message you saw?

Same page, same model, same prompt template. The only difference is 8 lines of recent-click history plus the popup’s a11y snapshot riding in the payload. The agent went from “which item?” to “I know exactly which item and what state it was in.” Figuring out why it’s suppressed still requires a separate lookup (a RAG hit or a tool call) — the activity trace gives the agent the what, not the why.

What I’d Do Differently

Declare every interface property on the class, not just required ones. TypeScript optional properties are tempting to leave undeclared, but the runtime semantics under various class-field transforms are subtle enough that an explicit field declaration is the safer default. Cost: one line. Benefit: a category of bugs you’ll never see.

Design the chat UI as an observer of the activity tracker, not a coordinator. Our first cut had the chat UI calling into the activity layer to request a fresh trace at send time, which coupled the two MFEs and introduced a “when is the trace ready?” synchronization problem. The cleaner pattern that emerged: the activity tracker pushes updates whenever something interesting happens; the chat UI just reads whatever state is current at send time. One-way data flow, no cross-MFE coordination. If we were starting over, that’s the contract we’d nail down on day one.

Treat MF integration as observation by policy, not capability. Module Federation remotes share the host’s document and global scope. Observation (event listeners, mutation observers) is trivial. Interception (patching window.fetch, wrapping a shared store, replacing a singleton) is also technically possible — it just couples your remote to the host’s internals and breaks the moment the host changes implementation. We adopted “observers only” as a policy after almost building a fetch-patching debug helper that would have rotted within a release. The runtime won’t enforce this for you.

Limitations You Should Know About

This is the section the post would lose credibility without. The pattern works well for our setup, but it has real gaps.

Shadow DOM is invisible. A capture-phase listener on document doesn’t see clicks inside closed shadow roots, and even open shadow roots retarget the event target at the shadow boundary. The walk-up parentElement chain also stops at the boundary. If the host page uses Web Components — LitElement, Stencil, anything that mounts a design system into shadow DOM, or Salesforce Lightning Web Components — your tracker will miss most user interactions inside those components. The workaround is to read event.composedPath() instead of event.target and walk that array, but you still can’t reach into a closed shadow root from the outside.

The role allowlist captures keystroke targets. textbox, searchbox, and combobox are in ACTIONABLE_ROLES, which means a click on an input followed by typing is recorded with the input’s accessible name. If a user’s row data — names, addresses, account numbers — appears in the row context we attach to each event, that data is now in the activity trace and ends up in the LLM prompt. Three implications:

  • PII / PHI / PCI: if your dashboard shows regulated data, the activity trace will exfiltrate it to whatever model provider you’re using. For PHI/PCI environments, this pattern likely isn’t deployable without on-prem inference and a redaction layer.
  • Passwords: <input type="password"> doesn’t usually carry role="textbox", but a custom component that wraps a password input can. Maintain an explicit denylist (e.g., any field inside [data-sensitive] or matching input[type=password]) and skip both the click and the row-context extraction.
  • Default to opt-in for row context: rather than always capturing sibling-cell text, mark tables that are safe with data-track-row-context="true". Anything else gets the click event without the row content.

Bursty interactions overflow the buffer. The 20-event ring buffer assumes a leisurely 1 click every 2-5 seconds. Power users clearing 19 filter chips, or anyone doing bulk-select-then-action, can flush the buffer in under 3 seconds. The 300ms debounce only catches identical-element repeats, not bursty distinct events. If your app has these workflows, consider coalescing repeats (“clicked 8 filter chips: A, B, C…”) or bumping the buffer to 50.

The “host-app exposure” assumption. Capturing events from a Module Federation remote works because the remote shares the host’s document. Same-origin iframes can do this via parent.document. Cross-origin iframes need a postMessage bridge the host opts into. Chrome extension content scripts get DOM access automatically but live in an isolated JS world, so you lose React-fiber introspection. The pattern generalizes; the transport doesn’t.

When This Pattern Applies

The core pattern — capture-phase listener, role-based filtering, circular buffer, compact NL serialization — works for AI agents embedded in role-rich, light-DOM SaaS UIs. Practical constraints:

  • You need DOM access to the host page. Module Federation in the same document is easiest. Same-origin iframes work via parent.document. Cross-origin iframes need a postMessage bridge. Pure Shadow-DOM hosts (Salesforce Lightning, heavy Web Components apps) need a different approach entirely.
  • You need semantic roles on interactive elements. ARIA roles, or at minimum semantic HTML (<button>, <a>, <input>), give you the vocabulary to describe what was clicked. Role-less sites (some Webflow / CMS-generated pages) won’t yield much.
  • You need a framing instruction in the LLM prompt. Without explicit guidance, models will treat the trace as metadata.
  • You need a redaction story for any field that could carry PII, PHI, or PCI data. Default-opt-in is the wrong default for regulated environments.

If you’re building something similar, I’d love to hear how you bridged the synchronous-event-meets-async-React-state gap. We went with a callback into setState; the obvious alternatives are a Zustand/Redux store the chat UI subscribes to, an RxJS subject, or a window-scoped event bus. Which would you have picked?

The accessibility tree gave the agent spatial awareness — what is on the page. The activity trace gives it temporal awareness — what the user was doing. Together, they let the agent answer the question every user actually asks: “I was just doing something — help me with that.”

PolyAI opens its Agentic Dialog Platform

NEW YORK — PolyAI, the Agentic Dialog Platform for building the conversational enterprise, today opened its platform to every builder. Free for the first two months, the technology powering the world’s most demanding customer conversations across 75 languages and 25 countries is now available to any team with an idea and an email address. For the … continue reading

The post PolyAI opens its Agentic Dialog Platform appeared first on SD Times.