Build Scoped JavaScript Eval Tools

Intern-friendly developer guide to designing, wiring, and debugging `eval_xxx` tools with `pkg/inference/tools/scopedjs`.

Sections

Terminology & Glossary
📖 Documentation
Navigation
58 sectionsv0.1
📄 Build Scoped JavaScript Eval Tools — glaze help geppetto-build-scopedjs-eval-tools
geppetto-build-scopedjs-eval-tools

Build Scoped JavaScript Eval Tools

Intern-friendly developer guide to designing, wiring, and debugging `eval_xxx` tools with `pkg/inference/tools/scopedjs`.

Tutorialgeppettotutorialjavascriptgojatoolsjs-bindings

This guide explains how to create your own scoped JavaScript tool in Geppetto using pkg/inference/tools/scopedjs. The audience is a new intern who knows Go, is comfortable reading code, but has not yet internalized how Geppetto turns a prepared goja runtime into one LLM-facing tool such as eval_fs_demo or eval_dbserver.

The main idea is simple, but the system has several moving parts that matter for correctness. Your application owns the runtime contents: which modules exist, which globals exist, which bootstrap JavaScript files run, and what scope the tool is allowed to see. scopedjs owns the reusable parts: booting the runtime, loading those components, building a good tool description, and exposing a consistent { code, input } -> { result, console, error } tool contract.

What You Will Build

By the end of this tutorial, you should be able to build a tool that feels like this:

  • the host application decides what "scope" means, such as one workspace, one account, or one temporary environment
  • Go code creates a prepared JavaScript runtime for that scope
  • the runtime exposes modules like require("fs"), globals like db or workspaceRoot, and helper functions loaded from bootstrap files
  • Geppetto registers that prepared runtime as one tool named eval_xxx
  • the model sends JavaScript source in code and optional structured input in input
  • the tool returns a structured EvalOutput

Concrete examples already exist in the repo:

  • cmd/examples/scopedjs-tool/main.go
  • cmd/examples/scopedjs-dbserver/main.go

Read those after this guide, not before. This guide gives you the mental model those examples assume.

Why Scoped JavaScript Tools Exist

Ordinary tools work best when the host already knows the exact function shape: get_weather(city) or lookup_ticket(id). That breaks down when the model needs to compose multiple capabilities in one step. If the task is "query the scoped data, write a file, create a note, and register a route", many tiny tools become awkward because the model has to coordinate state across multiple calls.

A scoped JavaScript tool solves that by moving the composition boundary into the runtime. Instead of advertising five tiny tools, you advertise one bounded tool whose environment is already prepared for a specific scope. The model can then write one small script against that environment.

That gives you:

  • flexibility: the model can compose capabilities in one call
  • control: the host still decides the exact runtime surface
  • reuse: the host app does not need to reimplement runtime bootstrap for every project
  • explainability: the generated tool description can tell the model exactly which modules, globals, and helpers exist

Mental Model

Think about the system as a pipeline, not as one magic function.

application scope
  workspace/account/request/session
        |
        v
EnvironmentSpec[Scope, Meta]
  RuntimeLabel
  Tool metadata
  DefaultEval options
  Configure callback
        |
        v
Builder
  AddNativeModule(...)
  AddGlobal(...)
  AddBootstrapSource(...)
  AddHelper(...)
        |
        v
BuildRuntime(...)
  create goja runtime
  install modules
  install globals
  run bootstrap JS
        |
        v
RegisterPrebuilt(...) or NewLazyRegistrar(...)
        |
        v
LLM-facing tool: eval_xxx
        |
        v
RunEval(...)
  input:  { code, input }
  output: { result, console, error, durationMs }

Why this matters: when something breaks, the fix depends on which layer failed. A missing global is a Configure(...) problem. A missing tool description detail is a ToolDescription or manifest problem. A promise rejection format issue is an eval/runtime problem.

System Map

These are the files you should understand before you change scopedjs behavior:

FileWhy it matters
pkg/inference/tools/scopedjs/schema.goPublic types: EnvironmentSpec, EvalInput, EvalOutput, EvalOptions, Builder manifest docs
pkg/inference/tools/scopedjs/builder.goBuilder methods for registering modules, globals, bootstrap sources, and helper docs
pkg/inference/tools/scopedjs/runtime.goBuildRuntime(...) and the code that converts builder state into a live goja runtime
pkg/inference/tools/scopedjs/eval.goRunEval(...), async wrapper execution, console capture, promise handling, timeout behavior
pkg/inference/tools/scopedjs/tool.goRegisterPrebuilt(...) and NewLazyRegistrar(...)
pkg/inference/tools/scopedjs/description.goHow the model-facing tool description is synthesized from your docs and manifest
cmd/examples/scopedjs-tool/main.goMinimal runnable example with fs and workspaceRoot
cmd/examples/scopedjs-dbserver/main.goComposed example with fs, fake webserver, fake obsidian, and a db global

The key design principle is separation of concerns:

  • your application decides what the runtime contains
  • scopedjs decides how to host and expose that runtime consistently

Prerequisites and Imports

Your Go file will need these imports to build a scopedjs tool:

import (
    "context"

    gojengine "github.com/go-go-golems/go-go-goja/engine"
    ggjmodules "github.com/go-go-golems/go-go-goja/modules"
    _ "github.com/go-go-golems/go-go-goja/modules/fs"  // side-effect import: registers the fs module

    "github.com/go-go-golems/geppetto/pkg/inference/tools"
    "github.com/go-go-golems/geppetto/pkg/inference/tools/scopedjs"
)

Important: the blank import _ "github.com/go-go-golems/go-go-goja/modules/fs" is required for the fs module to be available via ggjmodules.GetModule("fs"). Without it, GetModule returns nil. Add similar blank imports for any other go-go-goja modules you want to use.

Step 1: Decide the Runtime Boundary

Before you write code, decide what one eval tool is allowed to mean. This is the most important design step because a good runtime boundary keeps the tool understandable for both the model and the next developer.

Start by answering these questions:

  • What scope does one call operate on?
  • Which capabilities must be composable in one script?
  • Which capabilities should remain separate tools because they are too dangerous or too expensive?
  • Should runtime state persist across calls, or should each call start fresh?

Good boundary:

  • "This tool operates on one temporary workspace and can read files, query a scoped data facade, and produce note metadata."

Bad boundary:

  • "This tool can do whatever the whole application can do."

If you skip this step, the runtime usually becomes vague. That creates two downstream failures:

  • the tool description becomes too fuzzy for the model to use well
  • the host app quietly exposes too much ambient power to the runtime

Step 2: Define Scope and Meta

scopedjs is generic over two host-owned types:

type EnvironmentSpec[Scope any, Meta any] struct { ... }

Scope is the data required to build the runtime. Meta is extra information you want back after runtime construction.

Typical Scope examples:

  • a workspace root path
  • a struct containing AccountID, WorkspaceID, and a few already-opened handles
  • a session-local configuration object

Typical Meta examples:

  • counts of loaded fixtures
  • resolved names for status bars or telemetry
  • a label you want to show in logs

Pseudocode:

type DemoScope struct {
    WorkspaceRoot string
    AccountID     string
}

type DemoMeta struct {
    ProjectName string
    FileCount   int
}

Why this split matters:

  • Scope is input to runtime construction
  • Meta is output from runtime construction

Do not overload Meta with data the runtime actually needs to function. If JavaScript code needs something, put it in the runtime as a module, global, or bootstrap helper.

Step 3: Define EnvironmentSpec

EnvironmentSpec is the main configuration object. It tells scopedjs what to build and what the final tool should look like.

Core shape from pkg/inference/tools/scopedjs/schema.go:

type EnvironmentSpec[Scope any, Meta any] struct {
    RuntimeLabel string
    Tool         ToolDefinitionSpec
    DefaultEval  EvalOptions
    Describe     func() (EnvironmentManifest, error)   // optional
    Configure    func(ctx context.Context, b *Builder, scope Scope) (Meta, error)
}

What each field does:

  • RuntimeLabel — human-readable label for logs and error messages
  • Tool — model-facing tool metadata (name, description, starter snippets)
  • DefaultEval — default eval options (timeout, etc.); use DefaultEvalOptions() for sensible defaults
  • Describe — optional callback that returns a static EnvironmentManifest describing available modules, globals, helpers, and bootstrap files. When provided, this manifest is used for the tool description instead of (or merged with) what the builder collects during Configure. Useful when the manifest is known statically and you want to separate description from runtime wiring. If omitted, the manifest is built entirely from builder method calls inside Configure.
  • Configure — callback that receives a *Builder and populates it with modules, globals, bootstrap code, and helper docs. This is where the runtime is actually wired.

A minimal spec looks like this:

spec := scopedjs.EnvironmentSpec[DemoScope, DemoMeta]{
    RuntimeLabel: "project-ops",
    Tool: scopedjs.ToolDefinitionSpec{
        Name: "eval_project_ops",
        Description: scopedjs.ToolDescription{
            Summary: "Execute JavaScript in the scoped project runtime.",
            Notes: []string{
                "Use return to provide the final result.",
            },
            StarterSnippets: []string{
                `const rows = db.query("SELECT * FROM notes"); return rows;`,
            },
        },
        Tags:    []string{"javascript", "tools"},
        Version: "1.0.0",
    },
    DefaultEval: scopedjs.DefaultEvalOptions(),
    Configure: func(ctx context.Context, b *scopedjs.Builder, scope DemoScope) (DemoMeta, error) {
        // runtime wiring happens here
        return DemoMeta{}, nil
    },
}

Read that in two halves:

  • Tool describes what the model sees
  • Configure(...) describes what the runtime actually contains

If you forget to document the tool well, the runtime may work technically but still perform badly with the model because the generated description will not teach the model what it can call.

Step 4: Populate the Runtime with Builder

Configure(...) receives a *scopedjs.Builder. This is where you register the runtime contents. Most work happens here.

The main builder methods live in pkg/inference/tools/scopedjs/builder.go:

MethodUse it forTypical example
AddNativeModule(...)Existing go-go-goja native modulesfs, a custom native module
AddModule(...)Manual require(...) registrationA one-off module not implemented as NativeModule
AddGlobal(...)Scope-bound globals installed at runtime initdb, workspaceRoot, config
AddInitializer(...)Extra runtime init logicadvanced setup when globals are not enough
AddBootstrapSource(...)Inline helper JSjoinPath(...), helper wrappers
AddBootstrapFile(...)Preload a JS file from diskbootstrap/routes.js
AddHelper(...)Documentation for helper functionsjoinPath(a, b)

4a. Add native modules

This is how you expose modules that are loaded via require("...").

if err := b.AddNativeModule(fsModule); err != nil {
    return DemoMeta{}, err
}

How it works in practice:

  • you bring a go-go-goja native module
  • scopedjs records it in the manifest
  • BuildRuntime(...) installs it into the goja require registry

Why it matters: native modules are the cleanest way to expose reusable JS-facing APIs backed by Go.

4b. Add globals

Globals are installed during runtime initialization, not through require(...).

if err := b.AddGlobal("workspaceRoot", func(ctx *gojengine.RuntimeContext) error {
    return ctx.VM.Set("workspaceRoot", scope.WorkspaceRoot)
}, scopedjs.GlobalDoc{
    Type:        "string",
    Description: "Scoped root directory for this workspace.",
}); err != nil {
    return DemoMeta{}, err
}

Note: GlobalDoc also has a Name field, but AddGlobal(...) populates it automatically from the first argument. You only need to set Type and Description.

Use globals for data that is naturally ambient to the runtime:

  • a scoped path root
  • a prepared host facade like db
  • a small config object

Failure mode if you misuse globals: if you stuff too much behavior into one giant global object, the runtime becomes hard to document and hard to test. Prefer modules for larger capability surfaces.

4c. Add bootstrap JavaScript

Bootstrap code runs before the model's code executes. This is a good place for helper functions that are small, predictable, and easier to write in JavaScript than in Go.

if err := b.AddBootstrapSource("helpers.js", `
function joinPath(a, b) {
  return a.replace(/\/$/, "") + "/" + b.replace(/^\//, "");
}
`); err != nil {
    return DemoMeta{}, err
}

Then document it:

if err := b.AddHelper("joinPath", "joinPath(a, b)", "Join workspace-relative path segments."); err != nil {
    return DemoMeta{}, err
}

Why bootstrap exists:

  • it keeps tiny JS helpers out of Go glue code
  • it lets you shape the runtime ergonomics without creating a full module

4d. Full Configure(...) pseudocode

This is the shape you should have in your head:

Configure: func(ctx context.Context, b *scopedjs.Builder, scope DemoScope) (DemoMeta, error) {
    if err := b.AddNativeModule(fsModule); err != nil {
        return DemoMeta{}, err
    }

    if err := b.AddNativeModule(customModule); err != nil {
        return DemoMeta{}, err
    }

    if err := b.AddGlobal("db", func(ctx *gojengine.RuntimeContext) error {
        return ctx.VM.Set("db", newScopedDBFacade(scope))
    }, scopedjs.GlobalDoc{
        Type: "object",
        Description: "Scoped data facade for the current runtime.",
    }); err != nil {
        return DemoMeta{}, err
    }

    if err := b.AddBootstrapFile("bootstrap/helpers.js"); err != nil {
        return DemoMeta{}, err
    }

    if err := b.AddHelper("joinPath", "joinPath(a, b)", "Join scoped paths."); err != nil {
        return DemoMeta{}, err
    }

    return DemoMeta{
        ProjectName: scope.AccountID,
    }, nil
}

Step 5: Build the Runtime

BuildRuntime(...) turns your spec and scope into a live goja runtime plus manifest metadata. The implementation lives in pkg/inference/tools/scopedjs/runtime.go.

What BuildRuntime(...) does:

  • calls your Configure(...)
  • converts builder state into module specs and runtime initializers
  • creates the runtime factory
  • creates a runtime instance
  • loads bootstrap files and inline bootstrap sources
  • returns a BuildResult

The return type is:

type BuildResult[Meta any] struct {
    Runtime   *gojengine.Runtime
    Executor  *scopedjs.RuntimeExecutor
    Meta      Meta
    Manifest  EnvironmentManifest
    Cleanup   func() error
}

Why Manifest matters: this is how your builder docs become part of the generated model-facing description. If you forget to register docs when you register capabilities, the runtime works but the tool description becomes weaker.

Why Executor matters: Runtime is still the raw runtime handle, but Executor is the safe wrapper for reused-runtime evaluation. It serializes one full eval call on that runtime, which matters for prebuilt shared runtimes because one eval spans multiple phases internally.

Step 6: Choose Prebuilt vs Lazy Registration

After the runtime exists, you still need to expose it as a Geppetto tool. There are two modes.

Prebuilt: RegisterPrebuilt(...)

Use this when the runtime is safe and sensible to build ahead of time.

build scope now
    |
    v
BuildRuntime(...)
    |
    v
RegisterPrebuilt(...)
    |
    v
tool executes against that already-built runtime

Typical cases:

  • examples
  • one temp workspace for one process
  • a demo environment you already materialized

Code shape:

handle, err := scopedjs.BuildRuntime(ctx, spec, scope)
if err != nil {
    return err
}
defer handle.Cleanup()

registry := tools.NewInMemoryToolRegistry()
if err := scopedjs.RegisterPrebuilt(registry, spec, handle, scopedjs.EvalOptionOverrides{}); err != nil {
    return err
}

Important detail: RegisterPrebuilt(...) now uses handle.Executor internally, not the raw runtime directly. If you ever execute evals manually against a reused runtime, prefer handle.Executor.RunEval(...) over calling scopedjs.RunEval(...) on handle.Runtime yourself.

Lazy: NewLazyRegistrar(...)

Use this when the runtime depends on request or session context and should be built on demand.

tool call arrives
    |
    v
resolve scope from context
    |
    v
BuildRuntime(...)
    |
    v
RunEval(...)
    |
    v
cleanup runtime

Typical cases:

  • per-request account scoping
  • session-specific runtime views
  • environments that must not be reused across users

Code shape:

registrar := scopedjs.NewLazyRegistrar(spec, func(ctx context.Context) (DemoScope, error) {
    scope, ok := ctx.Value(scopeKey{}).(DemoScope)
    if !ok {
        return DemoScope{}, fmt.Errorf("missing scope")
    }
    return scope, nil
}, scopedjs.EvalOptionOverrides{})

if err := registrar(registry); err != nil {
    return err
}

How to choose:

Use prebuilt when...Use lazy when...
the scope is stable for the life of the processthe scope changes per call or per session
startup cost is acceptableruntime creation depends on request context
you want simple example codeyou need strong isolation between callers

Step 7: Understand the Eval Contract

The model-facing input and output types live in pkg/inference/tools/scopedjs/schema.go.

Input:

type EvalInput struct {
    Code  string         `json:"code"`
    Input map[string]any `json:"input,omitempty"`
}
  • Code — the JavaScript source the model (or caller) writes. It is wrapped in an async function, so await and return work naturally.
  • Input — optional structured data passed alongside the code. Inside the JavaScript execution context, it is available as the global variable input. For example, if the tool call includes "input": {"path": "/tmp/file.txt", "limit": 10}, the JS code can access input.path and input.limit. This lets the model separate data from logic: the code is the algorithm, the input is the parameters.

When calling the tool directly from Go (e.g. in tests), you provide Input as a map[string]any:

args, _ := json.Marshal(scopedjs.EvalInput{
    Code: `const fs = require("fs"); return fs.readFileSync(input.path);`,
    Input: map[string]any{
        "path": "/tmp/hello.txt",
    },
})
result, err := def.Function.ExecuteWithContext(ctx, args)

Output:

type EvalOutput struct {
    Result     any           `json:"result,omitempty"`
    Console    []ConsoleLine `json:"console,omitempty"`
    Error      string        `json:"error,omitempty"`
    DurationMs int64         `json:"durationMs,omitempty"`
}
  • Result — the value from return in the JavaScript code. Can be any JSON-serializable value.
  • Console — captured console.log(...), console.error(...), etc. Each entry has Level and Text.
  • Error — non-empty when the script threw, rejected a promise, or timed out. The model sees this as a normal tool result, not a crash.
  • DurationMs — wall-clock execution time in milliseconds.

The JavaScript is wrapped in an async function, so await and return work naturally:

const rows = db.query("SELECT * FROM notes");
console.log("loaded", rows.length);
return rows;

On the wire, the model sends JSON like:

{
  "code": "const rows = await db.query(\"SELECT * FROM users\"); return rows;",
  "input": {
    "limit": 10
  }
}

And receives:

{
  "result": [{ "id": 1, "name": "Ada" }],
  "console": [{ "level": "log", "text": "loaded users" }],
  "durationMs": 12
}

On rejection or timeout, the tool still returns a structured payload:

{
  "error": "Promise rejected: boom",
  "console": [],
  "durationMs": 4
}

Why this contract is useful:

  • the final result stays machine-friendly
  • console output stays visible but separate
  • the host can render the two differently in a UI

Step 8: Complete Minimal Example

This is the smallest realistic pattern, condensed from cmd/examples/scopedjs-tool/main.go.

fsModule := ggjmodules.GetModule("fs")

spec := scopedjs.EnvironmentSpec[string, struct{}]{
    RuntimeLabel: "fs-demo",
    Tool: scopedjs.ToolDefinitionSpec{
        Name: "eval_fs_demo",
        Description: scopedjs.ToolDescription{
            Summary: "Execute JavaScript against a scoped workspace with fs access.",
        },
    },
    DefaultEval: scopedjs.DefaultEvalOptions(),
    Configure: func(ctx context.Context, b *scopedjs.Builder, root string) (struct{}, error) {
        if err := b.AddNativeModule(fsModule); err != nil {
            return struct{}{}, err
        }
        if err := b.AddGlobal("workspaceRoot", func(ctx *gojengine.RuntimeContext) error {
            return ctx.VM.Set("workspaceRoot", root)
        }, scopedjs.GlobalDoc{
            Type: "string",
            Description: "Scoped workspace root.",
        }); err != nil {
            return struct{}{}, err
        }
        if err := b.AddBootstrapSource("helpers.js", `
function joinPath(a, b) { return a + "/" + b; }
`); err != nil {
            return struct{}{}, err
        }
        return struct{}{}, nil
    },
}

handle, err := scopedjs.BuildRuntime(ctx, spec, workspaceDir)
if err != nil {
    return err
}
defer handle.Cleanup()

registry := tools.NewInMemoryToolRegistry()
if err := scopedjs.RegisterPrebuilt(registry, spec, handle, scopedjs.EvalOptionOverrides{}); err != nil {
    return err
}

The important lesson is not the fs module itself. The lesson is the pattern:

  • define scope
  • define spec
  • populate builder
  • build runtime
  • register tool

Run the examples from the repo root with:

env GOWORK=off GOCACHE=/tmp/geppetto-go-build go run ./cmd/examples/scopedjs-tool
env GOWORK=off GOCACHE=/tmp/geppetto-go-build go run ./cmd/examples/scopedjs-dbserver

Step 9: Scaling Up — the Composed dbserver Shape

The minimal example above uses a single module and global. A real application may compose several capabilities into one runtime. The intended pattern for a larger tool looks like this:

eval_dbserver runtime
  - require("fs")
  - require("webserver")
  - require("obsidian")
  - global db
  - bootstrap sql_helpers.js
  - bootstrap routes.js

Pseudocode:

Configure: func(ctx context.Context, b *scopedjs.Builder, scope ServerScope) (Meta, error) {
    b.AddNativeModule(fsModule)
    b.AddNativeModule(webserverModule)
    b.AddNativeModule(obsidianModule)

    b.AddGlobal("db", func(ctx *gojengine.RuntimeContext) error {
        return ctx.VM.Set("db", NewScopedDBFacade(scope.DB))
    }, scopedjs.GlobalDoc{
        Type: "ScopedDBFacade",
        Description: "Database facade for the current scoped server environment.",
    })

    b.AddBootstrapFile("bootstrap/sql_helpers.js")
    b.AddBootstrapFile("bootstrap/routes.js")

    return Meta{}, nil
}

Then the model can write code like:

const rows = await db.query("SELECT id, title FROM notes ORDER BY id");
const server = require("webserver");

server.get("/notes", () => rows);

return {
  route: "/notes",
  count: rows.length
};

See cmd/examples/scopedjs-dbserver/main.go for the full runnable version of this pattern.

Step 10: Know Where Description Text Comes From

Many developers assume the tool description is only whatever they put in ToolDescription.Summary. That is not true.

pkg/inference/tools/scopedjs/description.go builds the final description from several sources:

  • ToolDescription.Summary
  • module docs gathered through the builder manifest
  • global docs gathered through the builder manifest
  • helper docs gathered through the builder manifest
  • bootstrap file names
  • eval state mode notes
  • starter snippets

This is why documentation must be added at registration time, not retrofitted later. If you expose a capability but do not document it through the builder or ToolDescription, the model gets a runtime it cannot fully understand.

Step 11: Test the Right Things

A good scopedjs tool should have tests at three levels.

1. Build tests

Verify the runtime builds and the expected tool is registered.

Questions to answer:

  • does BuildRuntime(...) succeed?
  • does the manifest include the expected modules/globals/helpers?
  • does the registry contain the final tool name?

2. Direct eval tests

Call RunEval(...) directly or execute the tool definition without a full UI or agent loop.

Questions to answer:

  • can JS call the expected modules and globals?
  • does the returned result shape match expectations?
  • is console capture working?

3. End-to-end behavior tests

Use a real tool loop or example binary if the runtime is meant for user-facing workflows.

Questions to answer:

  • does the model reliably choose the tool?
  • is the description strong enough?
  • are results rendered in a way humans can understand?

Pseudocode test plan:

test build
  -> runtime builds
  -> tool registered

test composed eval
  -> JS reads scoped data
  -> JS writes file or returns note metadata
  -> JS registers route or similar side effect

test error path
  -> missing file or invalid helper usage
  -> error surfaces in EvalOutput.Error

Common Design Mistakes

These mistakes show up early and cost time later.

MistakeWhy it hurtsBetter approach
stuffing everything into one giant globalhard to document and reason aboutuse modules for capability groups, globals for ambient context
giving the runtime ambient application accessweak isolation and surprising behaviorpass a narrow scope and expose only bounded facades
skipping helper docsthe model sees less than the runtime actually offersalways pair helper bootstrap with AddHelper(...)
writing examples before choosing state modeconfusing cross-call behaviordecide StatePerCall, StatePerSession, or StateShared up front
relying only on UI testshard to isolate failuresadd direct runtime/eval tests first

Troubleshooting

This table covers the failures you are most likely to hit when building your first tool.

ProblemCauseSolution
runtime is nil or tool registration failsBuildRuntime(...) did not succeed or handle.Runtime was not checkedfix the runtime build first, then register
require("fs") or another module failsthe module was never registered in Configure(...)call AddNativeModule(...) or AddModule(...)
a global like db is undefinedthe global binding was not installed or returned an errorverify AddGlobal(...) and the runtime initializer path
helper function is missingbootstrap source or file did not loadcheck AddBootstrapSource(...), AddBootstrapFile(...), and bootstrap errors
the model does not use the tool wellthe description is too vague or missing manifest docsimprove ToolDescription, global docs, module docs, and starter snippets
result output is hard to understandthe JS returns ad hoc shapesreturn a concise structured object with stable keys
error text is generic after promise rejectionrejection formatting may have lost JS error detailsinspect pkg/inference/tools/scopedjs/eval.go and verify current runtime behavior with a direct eval test

Operational Notes

  • Treat every module/global as a capability grant. If you expose a powerful object, the model has that power.
  • Prefer the lazy registration path first if you need a fresh runtime per request. Use prebuilt registration only when shared runtime reuse is intentional.
  • Keep helper bootstrap files small and well-documented. They become part of the tool contract.
  • Use the builder docs intentionally. The tool description is the model's API reference.
  1. Start with one prebuilt runtime and one small module/global pair.
  2. Verify RunEval(...) directly in tests.
  3. Register the tool through RegisterPrebuilt(...).
  4. Only then add lazy context-derived scope and more modules.
  5. Only then introduce larger app-specific compositions such as dbserver or obsidian automation.

See Also

  • Tools for the wider Geppetto tool model
  • Using Scoped Tool Databases for the analogous scopeddb pattern
  • cmd/examples/scopedjs-tool/main.go for the smallest runnable example
  • cmd/examples/scopedjs-dbserver/main.go for the composed multi-capability example