tjs-lang Technical Context

Note: This document provides a technical deep-dive into tjs-lang's architecture and security model. For a general overview, installation instructions, and usage examples, please refer to the main README.md.

tjs-lang is a secure, environment-agnostic runtime for executing AI agents and logic chains defined as JSON ASTs.

Bundle Size: ~17KB gzipped. Expressions are evaluated via lightweight AST nodes at runtime, eliminating the need for a parser library (the previous JSEP-based approach was ~50KB gzipped).

1. Architecture

The Builder (TypedBuilder)

A fluent TypeScript API that generates a portable JSON AST. It uses a Proxy to dynamically infer methods from the registered Atoms, providing a strongly-typed developer experience.

It is important to understand that the builder is only for constructing the AST; it does not contain any of the actual implementation logic for the atoms. All execution is handled by the Runtime.

Usage Pattern:

You can access the builder via Agent (for core atoms) or vm.Agent (the recommended way to access both core and any custom atoms registered with the VM instance).

import { Agent, s, AgentVM } from 'tjs-lang'

// Global Builder (Core Atoms)
const logic = Agent.take(s.object({ input: s.string }))
  .varSet({ key: 'sum', value: { $expr: 'binary', op: '+', left: { $expr: 'literal', value: 1 }, right: { $expr: 'literal', value: 1 } } })

// VM Builder (Custom Atoms)
const vm = new AgentVM({ myAtom })
const customLogic = vm.Agent
  .myAtom({ ... })
  .varSet({ ... })

The Runtime (AgentVM)

A stateless Virtual Machine that executes the AST. The runtime contains all the actual implementation logic for the atoms.

import { AgentVM } from 'tjs-lang'
const vm = new AgentVM()
const { result, fuelUsed } = await vm.run(ast, args, {
  capabilities,
  fuel: 1000,
})

2. Expression Syntax (ExprNode)

Expressions use AST expression nodes ($expr) for safe, sandboxed evaluation. Conditions in if and while atoms use expression strings that are parsed at transpile time.

For security, expressions are sandboxed and cannot directly access the agent's state. Use the vars parameter to explicitly pass variables from state into the expression scope.

ExprNode Types

Supported Operators

Security

Fuel Consumption

Each expression node evaluation consumes 0.01 fuel. This prevents deeply nested or recursive expressions from running unchecked. A simple a + b costs ~0.03 fuel (two identifiers + one binary op), while complex nested expressions accumulate cost proportionally.

3. Security Model

Execution Timeout

The VM enforces a hard timeout on execution to prevent hung agents—safeguarding against code that effectively halts by waiting on slow or non-responsive IO.

How it works:

  1. Automatic Safety Net: By default, timeout = fuel × 10ms. So 1000 fuel = 10 seconds. For IO-heavy agents with low fuel costs, explicitly set timeoutMs to prevent premature timeouts.
  2. Explicit SLA: Pass timeoutMs to enforce a strict time limit regardless of fuel.
  3. External Cancellation: Pass an AbortSignal to integrate with external controllers (user cancellation, HTTP timeouts, etc.).
// Default: 1000 fuel = 10 second timeout
await vm.run(ast, args, { fuel: 1000 })

// Explicit timeout: 5 seconds regardless of fuel
await vm.run(ast, args, { fuel: 10000, timeoutMs: 5000 })

// External abort signal
const controller = new AbortController()
setTimeout(() => controller.abort(), 3000) // Cancel after 3s
await vm.run(ast, args, { signal: controller.signal })

Resource Cleanup: When a timeout occurs, the VM passes the abort signal to the currently executing atom via ctx.signal. Loop atoms (while, map, filter, reduce, find) check the signal between iterations. httpFetch passes the signal to fetch for immediate request cancellation.

Timeout vs Fuel:

Both work together to ensure the VM cannot be held hostage by untrusted code.

Trust Boundary: The sandbox protects against malicious agents (untrusted AST), not malicious atom implementations. Atoms are registered by the host and are trusted to:

  1. Be non-blocking (no synchronous CPU-heavy work)
  2. Respect ctx.signal for cancellation
  3. Clean up resources when aborted

If you write custom atoms, ensure they check ctx.signal?.aborted in loops and pass ctx.signal to any async operations like fetch.

Cost Overrides

Default atom costs are guesses. Override them per-run to match your deployment reality:

await vm.run(ast, args, {
  costOverrides: {
    // Static: fixed cost per invocation
    httpFetch: 50,
    llmPredict: 500,

    // Dynamic: cost based on input
    storeSet: (input) => JSON.stringify(input.value).length * 0.001,
    llmPredict: (input) => (input.model?.includes('gpt-4') ? 1000 : 100),
  },
})

Use cases:

Request Context

The context option passes request-scoped metadata to atoms. Unlike args (agent input) or capabilities (IO implementations), context carries ambient data like auth, permissions, and request tracing.

await vm.run(ast, args, {
  context: {
    userId: 'user-123',
    role: 'admin',
    permissions: ['read:data', 'write:data', 'fetch:external'],
    requestId: 'req-abc-123',
  },
})

Atoms access it via ctx.context:

const secureFetch = defineAtom(
  'secureFetch',
  s.object({ url: s.string }),
  s.any,
  async (input, ctx) => {
    // Check permissions
    if (!ctx.context?.permissions?.includes('fetch:external')) {
      throw new Error('Not authorized for external fetch')
    }
    return ctx.capabilities.fetch(input.url)
  }
)

Design rationale:

Production patterns:

// Firebase/Express integration
app.post('/run-agent', async (req, res) => {
  const ast = req.body.ast
  const args = req.body.args

  // Extract auth from request
  const user = await verifyToken(req.headers.authorization)

  const result = await vm.run(ast, args, {
    context: {
      userId: user.id,
      role: user.role,
      permissions: user.permissions,
      requestId: req.id,
    },
    // User-tier-based costs
    costOverrides: {
      llmPredict: user.tier === 'premium' ? 10 : 100,
    },
  })

  res.json(result)
})

4. Stored Procedures

The procedure store provides a built-in mechanism for storing ASTs as callable tokens. This enables function-pointer-like patterns where behavior can be passed as data.

Storage Model

// Module-level storage in runtime.ts
const procedureStore = new Map<
  string,
  {
    ast: any
    createdAt: number
    expiresAt: number
  }
>()

Constants:

Token Resolution

Tokens can be used anywhere an AST is accepted:

  1. vm.run(token, args) - Direct execution via VM
  2. agentRun({ agentId: token, input }) - Execution from within an agent
  3. agentRun({ agentId: ast, input }) - Raw AST also accepted (no storage needed)

Resolution happens at runtime. If a string starts with proc_, the VM looks it up in the store. Expired or missing tokens throw clear errors.

Fuel Costs

Atom Cost Notes
storeProcedure 1.0 Plus 0.001 per byte of AST
releaseProcedure 0.5 Constant
clearExpiredProcedures 0.5 Plus 0.01 per procedure scanned

Security Considerations

Memory bounds: The maxSize parameter prevents storing arbitrarily large ASTs. Default 100KB is generous for most agents.

Expiry: TTL prevents memory leaks from abandoned procedures. The store is in-memory, so procedures don't survive process restarts.

No capability escalation: Stored procedures inherit the capabilities of the calling context, not the storing context. A malicious agent cannot store a procedure that later executes with elevated privileges.

Token predictability: Tokens are UUIDs, not sequential. They cannot be enumerated or guessed.

Use Cases

Dynamic dispatch (strategy pattern):

const strategies = ajs`
  function dispatch({ strategyToken, data }) {
    let result = agentRun({ agentId: strategyToken, input: { data } })
    return result
  }
`

Worker pool:

const orchestrator = ajs`
  function orchestrate({ workers, tasks }) {
    let results = []
    for (let i = 0; i < tasks.length; i = i + 1) {
      let workerToken = workers[i % workers.length]
      let r = agentRun({ agentId: workerToken, input: tasks[i] })
      results.push(r)
    }
    return { results }
  }
`

Callback registration:

const registerCallback = ajs`
  function register({ handler }) {
    let token = storeProcedure({ ast: handler, ttl: 300000 })
    return { callbackId: token }
  }
`

5. Production Considerations

Isomorphic Deployment (Universal Endpoint)

Because the VM is environment-agnostic and all IO is injected via capabilities, the same agent program runs unchanged in the data center and in the browser client — with one security model spanning both (capabilities + fuel + portable TJS RBAC rules). Atoms are the seam: a client getRecords can serve already-loaded data and fall back to the server-side getRecords only on a miss, eliminating round-trips. This emerged for free from the sandboxing design rather than being built for. See docs/universal-endpoint.md.

Recursive Agent Fuel

When an agent calls sub-agents via agentRun, each sub-agent gets its own fuel budget (passed via the capability). Fuel is not shared across the call tree by default.

Why: The agentRun atom delegates to ctx.capabilities.agent.run, which the host implements. This gives operators full control over sub-agent resource allocation.

Patterns for shared fuel:

// Option 1: Pass remaining fuel to children
const sharedFuel = { current: 1000 }

const caps = {
  agent: {
    run: async (agentId, input) => {
      if (sharedFuel.current <= 0) throw new Error('Out of shared fuel')
      const result = await vm.run(agents[agentId], input, {
        fuel: sharedFuel.current,
        capabilities: caps,
      })
      sharedFuel.current -= result.fuelUsed
      return result.result
    },
  },
}

// Option 2: Fixed budget per recursion depth
const caps = {
  agent: {
    run: async (agentId, input) => {
      // Each child gets 10% of parent's budget
      return vm.run(agents[agentId], input, {
        fuel: 100, // Fixed small budget
        capabilities: caps,
      })
    },
  },
}

Streaming and Long-Running Agents

The VM returns results only after complete execution. For long-running agents:

For real-time streaming, implement a custom atom that emits intermediate results:

const streamingAtom = defineAtom(
  'streamChunk',
  s.object({ data: s.any }),
  s.null,
  async ({ data }, ctx) => {
    // ctx.context contains your streaming callback
    await ctx.context?.onChunk?.(data)
    return null
  }
)

// Usage
await vm.run(ast, args, {
  context: {
    onChunk: (data) => res.write(JSON.stringify(data) + '\n'),
  },
})

Condition String Syntax

The condition parser in if/while atoms supports a subset of expression syntax:

Supported Example
Comparisons a > b, x == 'hello', n != 0
Logical a && b, a || b, !a
Arithmetic a + b * c, (a + b) / c
Member access obj.foo.bar
Literals 42, "string", true, null
Unsupported Alternative
Ternary a ? b : c Use nested if atoms
Array index a[0] Use ExprNode with computed: true
Function calls fn(x) Use atoms
Chained a > b > c Use a > b && b > c

Unsupported syntax now throws a clear error at build time with suggestions.

State Semantics

Agents are not transactional. If an atom fails mid-execution:

This is by design—agents are stateful pipelines, not database transactions. If you need atomicity, implement checkpoint/restore in your capabilities:

const caps = {
  store: {
    set: async (key, value) => {
      await db.runTransaction(async (tx) => {
        await tx.set(key, value)
      })
    },
  },
}

Error Handling Granularity

The try/catch atom catches all errors in the try block. There's no selective catch by error type.

Pattern for error type handling:

Agent.take(s.object({})).try({
  try: (b) => b.httpFetch({ url: '...' }).as('response'),
  catch: (b) =>
    b
      .varSet({ key: 'errorType', value: 'unknown' })
      // Check error message patterns
      .if(
        'msg.includes("timeout")',
        { msg: 'error.message' },
        (then) => then.varSet({ key: 'errorType', value: 'timeout' }),
        (el) =>
          el.if('msg.includes("404")', { msg: 'error.message' }, (then) =>
            then.varSet({ key: 'errorType', value: 'not_found' })
          )
      ),
})

9. Test Coverage

Summary (as of January 2025):

Metric Value
Tests 508
Functions 84.77%
Lines 80.36%

Coverage by Component

Core Runtime (security-critical):

File Functions Lines Notes
src/runtime.ts 100% 100% Re-exports
src/vm.ts 100% 100% Re-exports
src/vm/runtime.ts 84% 98% Atoms, expression eval, fuel
src/vm/vm.ts 90% 94% VM entry point
src/transpiler/index.ts 100% 100% AJS transpiler
src/builder.ts 92% 90% Fluent builder

Language/Transpiler:

File Functions Lines Notes
src/lang/emitters/ast.ts 94% 83% TJS → AST
src/lang/parser.ts 92% 82% TJS parser
src/lang/inference.ts 57% 60% Type inference (lower priority)

Test Categories:

Category Tests Coverage
Security (malicious actor) 10 Prototype access, SSRF, ReDoS, path traversal
Runtime core 25+ Fuel, timeout, tracing, expressions
Stress/Memory 6 Large arrays, deep nesting, memory pressure
Capability failures 10 Network errors, store failures, partial capabilities
Allocation fuel 4 Proportional charging for strings/arrays
Transpiler 50+ Language features, edge cases
Use cases 100+ RAG, orchestration, client-server patterns

Running Tests

# Full suite
bun test

# Fast (skip LLM and benchmarks)
SKIP_LLM_TESTS=1 AGENT99_TESTS_SKIP_BENCHMARKS=1 bun test

# With coverage
bun test --coverage

10. Dependencies

Runtime Dependencies

These ship with the library and affect bundle size and security posture.

Package Version Size Purpose Risk
acorn ^8.15.0 ~30KB JavaScript parser for AJS transpilation Low - Mature, widely audited, Mozilla-maintained
tosijs-schema ^1.2.0 ~5KB JSON Schema validation Low - Our library, 96.6% coverage, zero deps
@codemirror/* various ~50KB Editor syntax highlighting (optional) Low - Only loaded for editor integration

Total runtime footprint: ~33KB gzipped (core), ~83KB with editor support.

tosijs-schema 1.2.0 Coverage

Our validation dependency maintains comprehensive test coverage:

Because tosijs-schema schemas are JSON data (not code), library coverage extends to user-defined schemas—unlike Zod where user schema compositions are untested code.

Development Dependencies

Not shipped to users. Used for building, testing, and development.

Package Purpose Notes
typescript Type checking and compilation Standard
bun Runtime, bundler, test runner Fast, modern
eslint / prettier Code quality Standard
acorn-walk AST traversal for transpiler Only used at build time
codemirror Editor components for demo Demo only
tosijs / tosijs-ui Demo UI framework Demo only
happy-dom DOM mocking for tests Test only
vitest Alternative test runner Optional

Dependency Risk Assessment

Supply Chain:

Risk Mitigation
Acorn compromise Mature project (10+ years), Mozilla backing, widely used
tosijs-schema compromise We control this library
Transitive dependencies Minimal—acorn has 0 deps, tosijs-schema has 0 deps

Version Pinning:

Audit:

# Check for known vulnerabilities
bun audit
# or
npm audit

What We Don't Depend On

Notably absent from our dependency tree:

Common Dependency Why We Don't Use It
lodash Native JS methods suffice
axios Native fetch + capability injection
moment/dayjs Built-in Date wrapper in expressions
zod/yup tosijs-schema is lighter and sufficient
jsep Replaced with acorn + custom AST nodes

This minimal dependency approach reduces supply chain risk and bundle size.