Getting Started

Graal Script Agent lets Java applications offer prompt-authored extensions to their users. The application defines a Java interface that describes the allowed behavior and capabilities. A user describes the result they want, and a model authors a JavaScript or Python implementation of that interface.

The result is a Script<T> artifact rather than a source-code patch or a model response in the application’s request path. The application can execute it locally whenever needed. Generated scripts are treated as untrusted and run behind a schema-driven host boundary inside a GraalVM sandbox.

In this guide, you will build a ticket-routing extension. You will:

  • define a narrow Java extension interface;

  • discover and inspect its guest-visible schema;

  • connect Graal Script Agent to an OpenAI model;

  • generate a JavaScript implementation from a prompt;

  • execute the generated plugin inside an untrusted sandbox; and

  • invoke it through an ordinary Java interface.

Authoring the script makes a model request and requires an OpenAI API key. After generation, routing tickets is local: there is no model in the request path, no prompt assembled for each ticket, and no ticket data sent away for inference.

Add the Dependencies

The example requires Java 25. Add Graal Script Agent, its LangChain4j adapter, the LangChain4j OpenAI implementation, the Polyglot API, and the isolated GraalJS runtime to your application.

Maven
<dependencies>
    <dependency>
        <groupId>org.graalvm.scriptagent</groupId>
        <artifactId>scriptagent</artifactId>
        <version>0.1.0</version>
    </dependency>
    <dependency>
        <groupId>org.graalvm.scriptagent</groupId>
        <artifactId>scriptagent-langchain4j</artifactId>
        <version>0.1.0</version>
    </dependency>
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-open-ai-official</artifactId>
        <version>1.18.0-beta28</version>
    </dependency>
    <dependency>
        <groupId>org.graalvm.polyglot</groupId>
        <artifactId>polyglot</artifactId>
        <version>25.1.3</version>
    </dependency>
    <dependency>
        <groupId>org.graalvm.polyglot</groupId>
        <artifactId>js-isolate-community</artifactId>
        <version>25.1.3</version>
        <type>pom</type>
        <scope>runtime</scope>
    </dependency>
</dependencies>
Gradle
dependencies {
    implementation("org.graalvm.scriptagent:scriptagent:0.1.0")
    implementation("org.graalvm.scriptagent:scriptagent-langchain4j:0.1.0")
    implementation("dev.langchain4j:langchain4j-open-ai-official:1.18.0-beta28")
    implementation("org.graalvm.polyglot:polyglot:25.1.3")
    runtimeOnly("org.graalvm.polyglot:js-isolate-community:25.1.3")
}

The scriptagent artifact contains the core API. scriptagent-langchain4j connects its provider-independent model API to LangChain4j. The OpenAI artifact supplies the model implementation used below. polyglot supplies the embedding API, and js-isolate-community supplies the isolated JavaScript runtime required by Sandbox.UNTRUSTED. The Polyglot API and guest runtime must use the same version.

Configure the Model

Set your OpenAI API key in the environment rather than putting it in source code:

export OPENAI_API_KEY="your-api-key"

Read the key and create the model used during authoring:

String apiKey = System.getenv("OPENAI_API_KEY");
if (apiKey == null || apiKey.isBlank()) {
    throw new IllegalStateException("Set the OPENAI_API_KEY environment variable");
}

Model model = LangChainModel.of(OpenAiOfficialResponsesStreamingChatModel.builder()
        .apiKey(apiKey)
        .modelName("gpt-5.5")
        .build());

OpenAiOfficialResponsesStreamingChatModel uses OpenAI’s Responses API. The example uses gpt-5.5; substitute the latest model you want to use that supports streaming and tool calling.

LangChainModel adapts the LangChain4j model to Graal Script Agent’s provider-independent Model interface. Model configuration, credentials, rate limits, and cost remain the application’s responsibility.

Define the Extension Point

Add the ticket-routing contract to a GettingStarted class:

public final class GettingStarted {

    public interface Router {
        Queue route(Ticket ticket);
    }

    public record Ticket(boolean outage, boolean billing) {
    }

    public enum Queue {
        INCIDENT,
        BILLING,
        GENERAL
    }

    // Add main(String[]) in the following steps.
}

The host application owns this interface. It is part of the application and written by the developer. It is also the plugin contract and the host-application boundary for the extension.

Router is the root extension point. Ticket is the input supplied by the host, and Queue bounds the possible result. The application exposes this deliberately narrow contract instead of arbitrary services or its complete object graph.

Discover and Inspect the Schema

At the start of main, use JavaDiscovery.discover(Class) to derive a Schema and print it before making a model request:

Schema schema = JavaDiscovery.discover(Router.class);
System.out.println("Schema:");
System.out.println(schema);
System.out.println();

Graal Script Agent discovers the Router method, the Ticket fields, the Queue enum values, and the return type. It also attaches the reflection-backed host binding that will later connect the script to Java values.

Inspect this schema when designing an extension point. It is the allowlist shared by authoring and execution: it tells the model which types and members exist, and it bounds what the generated script can access at runtime. Make sure it contains the intended types and does not expose unintended capabilities.

The output is a compact tree. This excerpt abbreviates the fingerprint and helper details:

Schema[root=Router, fingerprint=...]
...
├── Queue traits=[EnumLike]
│   ├── type BILLING:Queue
│   ├── type GENERAL:Queue
│   └── type INCIDENT:Queue
├── Router [root, abstract, interface] traits=[Executable] typeTraits=[Implementable]
│   └── route(ticket:Ticket?) -> Queue?
├── Ticket
│   ├── outage:Boolean
│   └── billing:Boolean
...

Generate the Router

Use a ScriptAgent with the same schema to request a JavaScript implementation:

String prompt = "Route outages to INCIDENT, billing issues to BILLING, else GENERAL.";

Script<Router> script;
try (ScriptAgent agent = ScriptAgent.newBuilder(model)
        .language("js")
        .build()) {
    script = agent.generate(Router.class, schema, prompt);
}

ScriptAgent.generate(Class, Schema, String) makes the live model request. Depending on your provider account, it may incur cost.

Behind the scenes, Graal Script Agent turns the discovered schema into instructions for the model. The agent then runs an authoring loop until it has produced a valid script artifact for the Router contract or reports that it cannot complete the request.

The result is a Script<Router>, not an active Router. It contains the generated guest source and the schema against which that source was authored.

Inspect the Generated Script

Print the generated JavaScript:

System.out.println("Generated JavaScript:");
System.out.println(script.source().getCharacters());
System.out.println();

A generated implementation might look like this:

(ticket) => {
    if (ticket != null && ticket.outage === true) {
        return types.Queue.INCIDENT;
    }
    if (ticket != null && ticket.billing === true) {
        return types.Queue.BILLING;
    }
    return types.Queue.GENERAL;
}

The exact source can differ between runs. The types.Queue helper exposes the enum values declared by the schema.

You can inspect generated source for learning, diagnostics, or product features. Source review is not required for confinement. The script is still treated as untrusted and must execute behind the schema, host binding, sandbox, and application-policy boundaries.

Execute the Plugin Locally

Create an untrusted context with Sandbox.newContext(String), bind the script with Script.bind(Context), and call it through the Java interface:

try (Context context = Sandbox.UNTRUSTED.newContext(
        script.source().getLanguage())) {
    Router router = script.bind(context);
    List<Ticket> tickets = List.of(
            new Ticket(true, true),
            new Ticket(false, true),
            new Ticket(false, false));

    System.out.println("Result:");
    for (Ticket ticket : tickets) {
        System.out.println(router.route(ticket));
    }
}

As requested by the prompt, this prints:

Result:
INCIDENT
BILLING
GENERAL

This is the important part: the model writes the plugin, but it does not directly run the plugin. After generation, router.route(…​) is a local call into a sandboxed script that implements Router. Production tickets are not sent to the model.

Keep the Context open for as long as you use the bound Router. Do not retain or invoke the proxy after closing its context.

Sandbox.UNTRUSTED is appropriate when prompts or generated scripts may be malicious. If prompts come only from generally trusted users, Sandbox.CONSTRAINED may be sufficient. In either case, expose only capabilities that are safe for that extension point.

The sandbox constrains guest code, but exposed Java methods are still real application capabilities. If the host exposes a dangerous operation, the sandbox cannot make that operation safe. Host methods must still enforce authorization, validation, tenant boundaries, bounded resource use, and side-effect policy.

Add Authoring Feedback

A small schema and a clear prompt are often enough for a trivial router. Larger extension points benefit from tools that let the model inspect the application’s world, run its draft, and respond to validation feedback.

Replace the minimal generate(…​) call with a AuthoringTools configuration:

script = agent.generate(Router.class, schema, prompt,
        AuthoringTools.newBuilder()
                .mockExecution(Sandbox.UNTRUSTED)
                .sampleInspection(Sandbox.UNTRUSTED,
                        new Ticket(true, false),
                        new Ticket(false, true),
                        new Ticket(false, false))
                .testExecution(Sandbox.UNTRUSTED, Router.class, tests -> {
                    tests.test("routes representative tickets", router -> {
                        if (router.route(new Ticket(true, false)) != Queue.INCIDENT) {
                            throw new AssertionError("outage must route to INCIDENT");
                        }
                        if (router.route(new Ticket(true, true)) != Queue.INCIDENT) {
                            throw new AssertionError("outage must take precedence over billing");
                        }
                        if (router.route(new Ticket(false, true)) != Queue.BILLING) {
                            throw new AssertionError("billing must route to BILLING");
                        }
                        if (router.route(new Ticket(false, false)) != Queue.GENERAL) {
                            throw new AssertionError("other tickets must route to GENERAL");
                        }
                    });
                })
                .promptTokenLimit(10_000)
                .build());

These tools ground authoring in several layers:

  • The schema defines the methods, records, enum values, and reachable types the script may use.

  • Sample inspection lets the model inspect representative objects. Samples may become model-visible, so use synthetic or sanitized data when necessary.

  • Mock execution lets the model run a draft inside a sandbox, observe the result, and revise it.

  • Host-defined tests encode application expectations and boundary conditions. Completion succeeds only after all configured tests pass.

  • The prompt token limit keeps large schemas within the authoring budget. The model can request omitted schema details on demand.

Mock execution provides evidence to the model; host-defined tests decide whether the behavior is accepted. For meaningful application rules, test the expected outcomes rather than only checking that calls complete.

The complete source for this guide is GettingStarted.java.

Set Up Your Project

Use this chapter to add Graal Script Agent to an existing application. You need a JDK, one AI connector, and one guest-language runtime.

Install JDK 25

GraalVM JDK 25 is the recommended runtime. OpenJDK 25 can also use every sandbox mode when the application includes a Polyglot Isolate language dependency. See the GraalVM documentation for Polyglot Isolates.

Verify that your build uses JDK 25:

java -version

Add the Dependencies

Select either the LangChain4j or Spring AI dependency set. Guest-language dependencies are added separately at the end of this chapter.

Maven
LangChain4j
<dependencies>
    <dependency>
        <groupId>org.graalvm.scriptagent</groupId>
        <artifactId>scriptagent</artifactId>
        <version>0.1.0</version>
    </dependency>
    <dependency>
        <groupId>org.graalvm.scriptagent</groupId>
        <artifactId>scriptagent-langchain4j</artifactId>
        <version>0.1.0</version>
    </dependency>
    <dependency>
        <groupId>dev.langchain4j</groupId>
        <artifactId>langchain4j-open-ai-official</artifactId>
        <version>1.18.0-beta28</version>
    </dependency>
</dependencies>
Spring AI
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>2.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.graalvm.scriptagent</groupId>
        <artifactId>scriptagent</artifactId>
        <version>0.1.0</version>
    </dependency>
    <dependency>
        <groupId>org.graalvm.scriptagent</groupId>
        <artifactId>scriptagent-spring-ai</artifactId>
        <version>0.1.0</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
</dependencies>
Gradle
LangChain4j
dependencies {
    implementation("org.graalvm.scriptagent:scriptagent:0.1.0")
    implementation("org.graalvm.scriptagent:scriptagent-langchain4j:0.1.0")
    implementation("dev.langchain4j:langchain4j-open-ai-official:1.18.0-beta28")
}
Spring AI
dependencies {
    implementation(platform("org.springframework.ai:spring-ai-bom:2.0.0"))
    implementation("org.graalvm.scriptagent:scriptagent:0.1.0")
    implementation("org.graalvm.scriptagent:scriptagent-spring-ai:0.1.0")
    implementation("org.springframework.ai:spring-ai-starter-model-openai")
}

Configure OpenAI

Graal Script Agent has a provider-independent Model API. Select either LangChain4j or Spring AI to connect that API to OpenAI. Both examples use gpt-5.5 and read the API key from OPENAI_API_KEY.

LangChain4j

Construct a streaming OpenAI model and adapt it with LangChainModel:

Model model = LangChainModel.of(
        OpenAiOfficialResponsesStreamingChatModel.builder()
                .apiKey(System.getenv("OPENAI_API_KEY"))
                .modelName("gpt-5.5")
                .build());

See the LangChain4j OpenAI documentation for the complete client configuration.

Spring AI

Configure the OpenAI starter in application.properties:

spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-5.5

Adapt the auto-configured model with SpringAiModel in a Spring bean:

@Bean
Model scriptAgentModel(OpenAiChatModel openAiModel) {
    return SpringAiModel.of(openAiModel);
}

See the Spring AI OpenAI documentation for the complete starter configuration.

An OpenAI API key uses OpenAI Platform API billing. As an optional alternative for a ChatGPT/Codex subscription, the third-party AIProxyOauth project can expose a local OpenAI-compatible endpoint. It is not part of Graal Script Agent or an official OpenAI API authentication mechanism.

Configure Other Providers

Both connectors can use Anthropic or another tool-capable provider. Follow the corresponding framework documentation:

LangChain4j delegates must implement StreamingChatModel. Spring AI delegates must provide a ChatModel whose options support tool definitions.

Add a Guest-Language Runtime

Graal Script Agent does not bundle JavaScript or Python. It is compiled against Polyglot 25.0.0 as its compatibility baseline. Applications select their GraalVM runtime version by declaring org.graalvm.polyglot:polyglot directly and using that exact version for the guest-language artifact. In Gradle builds with competing GraalVM dependencies, use strict version constraints so conflict resolution cannot upgrade only one artifact.

Runtime Community/open-source artifact GFTC artifact

JavaScript, non-isolated for CONSTRAINED on GraalVM JDK

org.graalvm.polyglot:js-community

org.graalvm.polyglot:js

Python, non-isolated for CONSTRAINED on GraalVM JDK

org.graalvm.polyglot:python-community

org.graalvm.polyglot:python

JavaScript isolate for every sandbox mode

org.graalvm.polyglot:js-isolate-community

org.graalvm.polyglot:js-isolate

Python isolate for every sandbox mode

org.graalvm.polyglot:python-isolate-community

org.graalvm.polyglot:python-isolate

Artifacts ending in -community use the community/open-source distribution. Artifacts without that suffix use the GraalVM Free Terms and Conditions. The isolate artifacts work on GraalVM JDK 25 and OpenJDK 25. Use a non-isolated artifact when choosing Sandbox.CONSTRAINED on GraalVM JDK.

The recommended default, matching the Getting Started example, is the community JavaScript isolate:

Maven
<dependency>
    <groupId>org.graalvm.polyglot</groupId>
    <artifactId>polyglot</artifactId>
    <version>25.1.3</version>
</dependency>
<dependency>
    <groupId>org.graalvm.polyglot</groupId>
    <artifactId>js-isolate-community</artifactId>
    <version>25.1.3</version>
    <type>pom</type>
    <scope>runtime</scope>
</dependency>
Gradle
dependencies {
    implementation("org.graalvm.polyglot:polyglot:25.1.3")
    runtimeOnly("org.graalvm.polyglot:js-isolate-community:25.1.3")
}

Choose another artifact from the table by replacing the guest-runtime artifact name. Keep both Polyglot and the guest runtime at version 25.1.3.

Design an Extension Point

An extension point is an application-owned contract that a generated script implements. The following recommendations are guidelines for designing that contract and its script-visible types. They are not requirements imposed by Graal Script Agent.

Design Recommendations

  • Keep each extension point narrow and bounded. Model one business capability, expose only the information and operations needed to implement it, and prefer bounded task-level methods over low-level getters, repositories, or general service facades. Make query, iteration, allocation, and result limits explicit.

  • Prefer interfaces for behavior. Use Java interfaces for root extension points and capability boundaries whenever possible.

  • Use dedicated data types. Represent immutable structured data with records, finite values with enums, and closed alternatives with sealed hierarchies.

  • Seal abstract base interfaces. If an interface groups alternatives but is not itself a valid script implementation, declare it sealed and enumerate its permitted subtypes. Discovery can then expose the intended implementation choices.

  • Own every exposed type. Schema-visible types should be supported JDK scalar or collection types, or dedicated application-owned contracts. Never expose third-party libraries, framework types, persistence entities, transport objects, or mutable implementation types.

  • Design for composition. Prefer small extension points with compatible application-owned contracts so multiple script-backed plugins can be combined. Composable plugins provide more functionality than isolated, monolithic extension points.

  • Prefer explicit domain types. Avoid Object, Any, generic maps, and strings that encode undocumented structures or commands.

  • Use clear domain vocabulary. Method and type names should communicate intent without lengthy prompt instructions.

  • Keep contracts stable. Schema changes can invalidate persisted scripts, so evolve extension points deliberately and version incompatible contracts.

  • Test extension points independently. Verify the exposed contract, implementation choices, bounds, and composition behavior without relying on model-generated scripts.

Define the Contract in Java

Most extension points should begin with a public Java interface. Non-sealed interfaces represent behavior that a generated script can implement.

Use Java type shapes according to their purpose:

  • Interfaces define behavior and capabilities.

  • Records represent immutable structured data.

  • Enums represent finite sets of values.

  • Sealed interfaces and classes represent closed alternatives.

An abstract base interface that is not itself a valid implementation should be sealed. Its permitted subtypes tell discovery, and therefore the authoring model, which alternatives are available.

Names should carry as much of the contract as possible. Add @SchemaDoc where domain meaning, constraints, or relationships are not clear from a type or member name. Use @SchemaExclude to omit an incidental public member, but prefer a dedicated narrow contract over discovering a broad type and excluding most of it.

Discover the Contract

JavaDiscovery derives a Schema from a public Java entry point and its reachable types:

Schema schema = JavaDiscovery.discover(MyExtension.class);

Discovery preserves the nominal Java contract, including interfaces, records, methods, enums, sealed hierarchies, and supported collection shapes. A non-sealed interface is treated as implementable by a generated script. A sealed interface is treated as a closed set of permitted alternatives.

Each callable on a schema surface needs a unique script-visible name. JavaDiscovery assigns deterministic names when Java overloads or other members collide. Scripts use the names rendered in the discovered schema; inspect that schema rather than deriving them.

ScriptAgent.generate(Class, String) performs this discovery automatically. Call JavaDiscovery.discover(…​) directly when you want to inspect or print the schema, configure discovery, or reuse the schema across authoring operations.

Use the Attached Java Host Binding

The schema returned by JavaDiscovery has a reflection-backed JavaHostBinding attached to it. The two are produced together:

  • The Schema declares what generated scripts can see and call.

  • The JavaHostBinding invokes the corresponding Java members and converts values between Java and the guest language.

Keeping them attached ensures that the runtime implementation matches the discovered contract. Java reflection details remain inside the binding and do not become part of the guest-language API.

Configure Discovery

JavaDiscoveryConfig customizes how the reachable Java API becomes a schema:

JavaDiscoveryConfig config = JavaDiscoveryConfig.newBuilder()
        .includeClassesMatching(type ->
                type.getPackageName().startsWith("com.example.extensions"))
        .build();

Schema schema = JavaDiscovery.discover(MyExtension.class, config);

Configuration can control:

  • which reachable classes are deeply discovered;

  • which methods are included;

  • which collection and executable traits are enabled;

  • whether trait members are exposed;

  • whether record components or bean properties become fields;

  • how application annotations on members become schema tags or attributes;

  • whether the built-in schema annotations are processed; and

  • whether Java reflection types are discoverable.

Use configuration to narrow or adapt an application-owned contract. Do not use it to turn a third-party API into an extension point.

Other JVM Languages

Java API discovery is currently the only built-in discovery path. Graal Script Agent does not provide Kotlin- or Scala-aware discovery.

An application or future integration can add language-aware discovery by producing the same two artifacts: a Schema describing the script-visible contract and a matching HostBinding implementing it. This allows Kotlin or Scala discovery to compose with the existing authoring and execution model without requiring either subsystem to understand those languages.

Add Schema Metadata

JavaDiscovery processes Graal Script Agent’s schema annotations by default. Use them for durable information about the extension-point contract instead of repeating that information in authoring prompts.

Annotation Purpose Important behavior

@SchemaDoc

Describes a type, member, or type use

The description is shown during authoring and becomes part of the schema.

@SchemaExclude

Excludes a type or member from deep discovery

An excluded reachable type remains an opaque nominal type, but its members are not exposed.

@SchemaTag

Adds a label to an element or type use

Applications and authoring tools decide how to interpret the label.

@SchemaAttribute

Adds a string key-value pair

Applications and authoring tools decide how to interpret the attribute.

@SchemaPromptRender

Controls whether details appear in the authoring prompt

It does not change runtime access. The root interface and its declared instance members cannot be omitted.

These annotations can also annotate application-defined annotations. This allows an application annotation to contribute consistent schema metadata wherever it is used. JavaDiscoveryConfig can alternatively map existing annotations on members to tags or attributes.

Descriptions, render modes, tags, and attributes are part of the logical schema and affect its fingerprint. Changing them can make the schema incompatible with persisted scripts.

@SchemaDoc, @SchemaTag, @SchemaAttribute, and @SchemaPromptRender describe or render the contract. They do not remove runtime access or enforce application policy. Use dedicated contract types, @SchemaExclude, and JavaDiscoveryConfig to shape the discovered API.

Prefer annotations for stable facts about the extension point. Reserve ScriptAgent.Builder.instructions(String) for stable authoring guidance that is not already expressed by schema documentation.

Add Script Properties

Applications often need information about a generated plugin in addition to its source code. For example, an application may need a short description to:

  • show the plugin in a list or chooser;

  • explain its behavior on a details or review screen; or

  • help users search saved plugins.

Script properties let the authoring model provide this information when it creates the plugin. The application can then read it directly from the resulting Script without executing the plugin or making another model request.

Use script properties primarily for short human-readable descriptions rather than as a general metadata system.

Declare a description property on the root extension-point type:

@SchemaProperty(
        name = "description",
        prompt = "Describe what this plugin does in one sentence.")
public interface Router {
    Queue route(Ticket ticket);
}

@SchemaProperty can only be declared on the root extension-point type. Every declared script property is a required string. The authoring model supplies its value when it completes the script.

Read the description with Script.requireProperty(String):

String description = script.requireProperty("description");

Script properties describe the generated plugin; they are not execution inputs. Data that changes between executions belongs in extension-point method parameters.

@SchemaDoc and @SchemaProperty serve different purposes:

  • @SchemaDoc explains the extension-point API to the authoring model.

  • @SchemaProperty asks the authoring model to describe the plugin it created.

Nominal Access and Scalar Mapping

Guest scripts interact with the contract declared by the Schema, not with unrestricted Java objects. A script can access only members and operations declared by schema elements and traits. Passing an object whose runtime Java class has additional public members does not make those members visible.

Java Object is discovered as Any. Unresolved generic type variables that erase to Object can also introduce Any. It allows a value whose precise type is not expressed at that position, but it does not grant reflective access to arbitrary Java objects. Host values flowing through a Java-backed Any position must be supported scalars or objects that the attached JavaHostBinding resolves to a nominal type already declared in the schema; arbitrary maps, collections, and unrelated objects are not projected. Prefer precise application-owned types or sealed alternatives. Use Any only when the value type is genuinely open, and keep it localized to the smallest part of the contract.

JavaDiscovery maps Java primitive and boxed types to schema scalar types. Conversion is strict: incompatible or lossy values are rejected rather than coerced.

Java declaration Guest value Conversion rules

boolean, Boolean

Boolean

Requires a Boolean value, not a number or string.

byte, Byte, short, Short, int, Integer, long, Long

Integer

Must be integral, within the declared Java type’s range, and exactly representable.

float, Float, double, Double

Number

Must be finite and exactly representable as the declared Java type.

char, Character

String

Must have a length of exactly one Java UTF-16 code unit.

String, CharSequence

String

A Java CharSequence value is exposed as a guest-language string.

Primitive type uses are non-null. Boxed scalars and other reference types are nullable by default. @NullMarked on the enclosing Java contract or package makes reference types non-null by default, while @Nullable marks an individual type use as nullable. Nullability and scalar conversion rules are presented to the authoring model as part of the schema.

Model Dynamic Structure

Some applications expose structures that vary by tenant or user configuration, such as custom properties or configurable record types. The recommended approach is to define one static generic schema and provide representative examples for inspection, rather than constructing a different nominal schema for every concrete shape.

For example, this extension point represents arbitrary properties through one application-owned structure:

@NullMarked
public interface PropertyRule {

    boolean matches(Properties properties);

    @NullMarked
    record Properties(List<Property> entries) {
    }

    @NullMarked
    record Property(String name, Object value) {
    }
}

The schema remains static: every property has a name and a value. Only the concrete property names and values vary. Object maps to Any, but its use is localized to the individual property value.

Provide representative properties when authoring the script:

PropertyRule.Properties sampleProperties =
        new PropertyRule.Properties(List.of(
                new PropertyRule.Property("department", "support"),
                new PropertyRule.Property("priority", 4),
                new PropertyRule.Property("escalated", true)));

AuthoringTools tools = AuthoringTools.newBuilder()
        .sampleInspection(Sandbox.UNTRUSTED, sampleProperties)
        .build();

Script<PropertyRule> script =
        agent.generate(PropertyRule.class, prompt, tools);

AuthoringTools.Builder.sampleInspection(…​) allows the authoring model to examine the available property names, scalar value types, and conventions before implementing the rule. Structured property values must use application-owned nominal types declared in the schema. The contract stays fixed while the examples provide concrete information about the current application data.

Samples are authoring-time evidence. They are not available to the completed script, do not change the schema, and do not grant additional runtime access. Supply examples that demonstrate relevant shapes and conventions, but do not treat them as proof of every possible input.

Only build a dynamic Schema with a matching custom HostBinding when a static generic structure and sample inspection cannot represent the required domain. See Build Dynamic Schemas for that advanced approach.

Select Between Sealed Extension Types

Use a sealed hierarchy when one authoring entry point should allow the model to select between a closed set of related extension capabilities. The sealed base groups the alternatives, while each permitted non-sealed interface defines one implementable capability:

public sealed interface TicketExtension
        permits TicketExtension.Router, TicketExtension.Reply {

    non-sealed interface Router extends TicketExtension {
        Queue route(Ticket ticket);
    }

    non-sealed interface Reply extends TicketExtension {
        String write(Ticket ticket);
    }

    record Ticket(String subject) {
    }

    enum Queue {
        SUPPORT,
        BILLING
    }
}

Use the sealed hierarchy directly as the authoring root. JavaDiscovery exposes Router and Reply as permitted alternatives. The generated script selects and implements one of them; it cannot implement TicketExtension directly.

Script<TicketExtension> script =
        agent.generate(TicketExtension.class, prompt);

TicketExtension selected = script.bind(context);
Object result = switch (selected) {
    case TicketExtension.Router router -> router.route(ticket);
    case TicketExtension.Reply reply -> reply.write(ticket);
};

Identify the selection through its nominal subtype rather than the generated proxy class. Use a sealed hierarchy when the alternatives form one conceptual choice and share the same lifecycle and review process. Prefer separate root extension points when the application chooses the capability before authoring or when alternatives require different prompts, validation, persistence, or review.

Generate and Revise Scripts

Authoring turns a natural-language request into a typed Script<T> whose guest-language source implements an application extension point. ScriptAgent coordinates the model, schema, language support, draft, and authoring tools until the script reaches successful completion.

The model participates only while a script is generated or revised. After authoring, binding and invoking the completed script execute locally without another model call.

Before authoring, complete Set Up Your Project.

Graal Script Agent has a provider-independent Model boundary. Refer to the LangChain4j model documentation or Spring AI chat-model documentation for provider-specific configuration. Getting Started uses LangChain4j with OpenAI because it is the best-tested example, not as a general provider recommendation.

JavaScript is the default authoring language. Select Python with language("python"); select JavaScript explicitly with language("js"). The corresponding guest-language runtime must be available as described in the setup chapter.

Choose an Authoring Workflow

Use ScriptAgent.generate(Class, String) for a standalone request. It creates a short-lived authoring session, produces one completed Script<T>, and does not retain the conversation after returning.

The following example uses the Router extension point from Getting Started:

Script<Router> script = agent.generate(
        Router.class,
        """
        Route outages to INCIDENT, billing questions to BILLING,
        and all other tickets to GENERAL. Outages take precedence.
        """);

generate(Class<T>, String) discovers the schema from the Java interface. Use the overload that accepts a Schema when the application supplies the schema explicitly.

Use a ScriptAgent.Session<T> when users need to inspect, critique, or refine a script through multiple prompts. Each successful turn retains the current script and complete model transcript for the next turn:

Schema schema = JavaDiscovery.discover(Router.class);

try (ScriptAgent.Session<Router> session =
        agent.newSession(Router.class, schema)) {

    Script<Router> initial = session.generate(
            "Route outages to INCIDENT and all other tickets to GENERAL.");

    Script<Router> refined = session.generate(
            "Keep that behavior and add routing of billing questions to BILLING. " +
            "Outages must still take precedence.");
}

The second request builds on the first request and its completed script. Use separate one-shot generate(…​) calls instead when requests do not share context.

Use ScriptAgent.revise(Script, String) to start a new one-shot authoring run from an existing script:

Script<Router> revised = agent.revise(
        script,
        "Keep the current routes, but make BILLING take precedence when " +
        "a ticket is both an outage and a billing question.");

The existing script supplies the initial source and schema. Its original model conversation is not part of the Script<T> artifact. Use ScriptAgent.newSession(Script) instead when several follow-up revisions should share one new conversation.

Write Effective Authoring Requests

Describe the behavior the completed extension should implement. Include relevant outcomes, priorities, boundary cases, and examples that are not already expressed by the Java contract.

Prefer:

Route outages to INCIDENT. Otherwise route billing questions to BILLING.
Route every remaining ticket to GENERAL.

Avoid repeating method signatures, record fields, enum values, or authoring-tool instructions. ScriptAgent already renders the schema and tool contract for the model. Repeating them increases prompt size and risks contradicting the actual contract.

Use ScriptAgent.Builder.instructions(String) for brief, stable guidance that applies to every script authored by an agent. Keep task-specific requirements in the prompt:

ScriptAgent agent = ScriptAgent.newBuilder(model)
        .language("js")
        .instructions("Prefer direct, deterministic implementations.")
        .build();

Do not use global instructions to restate schema documentation or encode behavior that belongs in an individual extension request.

Understand the Authoring Loop

Each generation or revision run maintains one guest-language source file. The model reads, replaces, or edits that draft through authoring tools. Draft mutations carry a revision number; an edit based on a stale revision is rejected so the model can inspect the current source before trying again.

Each authoring step sends the current conversation and available tool definitions to the model. The model may inspect additional schema details, update the draft, and use configured tools to gather evidence about its behavior.

Authoring finishes through an explicit terminal tool call. Plain assistant text is not completion. Before returning a Script<T>, completion validates the source and runs any completion checks supplied by the configured AuthoringTools.

Authoring-tool failures and rejected completion checks return structured feedback so the model can revise the draft and try again. An explicit terminal failure, exhausted configured bound, or non-retryable model failure ends the run without replacing an existing Script<T>.

See Improve Authoring with Tools to add mock execution, sample inspection, tests, and custom tools.

Bound Model Work

Generation and revision are blocking operations. Configure ScriptAgent.Builder to bound retries, authoring steps, and periods without model progress:

ScriptAgent agent = ScriptAgent.newBuilder(model)
        .language("js")
        .maxRetries(2)
        .maxAuthoringSteps(32)
        .responseTimeout(Duration.ofMinutes(2))
        .build();

ScriptAgent.Builder.maxRetries(int) bounds retries after retryable model failures or invalid model responses. ScriptAgent.Builder.maxAuthoringSteps(int) bounds the model-and-tool loop within an attempt.

Graal Script Agent does not currently compact authoring history. These settings bound model work, but they do not bound the accumulated transcript size across session turns.

ScriptAgent.Builder.responseTimeout(Duration) is an inactivity timeout. Non-empty streaming progress resets it; it is not a total duration limit for the complete authoring run. Whether progress is available depends on the provider, model, and adapter.

Interrupting the calling thread stops the local wait and interrupts the model invocation. A timeout does the same and ignores late events or responses. Provider-side cancellation is best effort: remote work may continue temporarily and may still consume provider capacity or tokens.

Manage Model Context and Data

A session replays the complete typed transcript of its successful turns on every subsequent generation. A failed generation does not add a turn or replace the session’s current script. History is not automatically truncated, compacted, or summarized. Long-lived sessions therefore increase model context use, latency, and cost, and may eventually reach the provider’s context-window limit.

Prefer short-lived sessions scoped to one authoring task. Session.clear() discards the accumulated history and current script, so the next prompt starts without either. Try-with-resources scopes session use, but Session.close() currently does not clear its state. Sessions are in-memory objects and cannot currently be serialized or restored. Persist the completed Script<T> when it must outlive the session.

The rendered schema, authoring request, draft source, retained conversation, tool definitions, and tool results can all become visible to the configured model provider. Samples and application data exposed through authoring tools can also become model-visible. Supply only data that is appropriate for that provider, using synthetic or sanitized examples where necessary.

Improve Authoring with Tools

Authoring tools operate behind the scenes during model-backed generation and revision. The model uses them to edit its draft, inspect omitted schema details, test behavior, complete the script, or report that the request cannot be implemented. They do not become part of the completed script’s runtime API.

AuthoringTools.MINIMAL is the default. It does not execute guest code and requires no sandbox. Use it when authoring-time guest execution is unavailable.

For normal authoring, start with sandboxed mock execution:

AuthoringTools tools =
        AuthoringTools.recommended(Sandbox.UNTRUSTED);

Script<Router> script =
        agent.generate(Router.class, schema, prompt, tools);

AuthoringTools.recommended(Sandbox) adds mock execution to the core tool set. It lets the model run the current draft against scenarios inside the selected sandbox, but it does not add sample data or completion tests.

The sandbox applies only to authoring-time execution. Production code must select its execution sandbox independently, as described in Execute Scripts Safely.

Combine Complementary Evidence

Mock execution, sample inspection, and completion tests serve different purposes:

Tool Purpose

Mock execution

Lets the model exercise scenarios it chooses against schema-shaped mocks.

Sample inspection

Lets the model inspect representative application values and conventions.

Completion tests

Checks host-defined expectations whenever the model submits a script for completion.

Combine them when the extension requires both domain understanding and host-controlled acceptance:

AuthoringTools tools = AuthoringTools.newBuilder()
        .mockExecution(Sandbox.UNTRUSTED)
        .sampleInspection(
                Sandbox.UNTRUSTED,
                new Ticket(true, false),
                new Ticket(false, true),
                new Ticket(false, false))
        .testExecution(
                Sandbox.UNTRUSTED,
                Router.class,
                tests -> {
                    tests.test(
                            "outages take precedence",
                            router -> {
                                Queue result = router.route(
                                        new Ticket(true, true));
                                if (result != Queue.INCIDENT) {
                                    throw new AssertionError(
                                            "outage must route to INCIDENT");
                                }
                            });

                    tests.test(
                            "unmatched tickets use the fallback",
                            router -> {
                                Queue result = router.route(
                                        new Ticket(false, false));
                                if (result != Queue.GENERAL) {
                                    throw new AssertionError(
                                            "fallback must route to GENERAL");
                                }
                            });
                })
        .build();

These configurations remain independent. Sample objects are not automatically injected into mock scenarios or completion tests.

Mock Execution

After the application enables mock execution with AuthoringTools.Builder.mockExecution(Sandbox), the model can invoke its current draft using schema-shaped arguments and mocked host behavior. This happens inside the authoring loop; application developers and end users do not construct the tool calls.

For example, the model can call run_script_mock_tests with multiple Router scenarios:

{
  "mocks": {
    "route": {
      "ticket": {
        "script": "scenario.ticket"
      }
    }
  },
  "types": "({ Queue: { INCIDENT: 'INCIDENT', BILLING: 'BILLING', GENERAL: 'GENERAL' } })",
  "scenarios": [
    {
      "name": "outage",
      "script": "({ ticket: { outage: true, billing: false } })"
    },
    {
      "name": "billing",
      "script": "({ ticket: { outage: false, billing: true } })"
    },
    {
      "name": "fallback",
      "script": "({ ticket: { outage: false, billing: false } })"
    }
  ]
}

Each mock script supplies the complete root parameter object used by the current draft. The types script supplies the enum helpers used by the draft, and the scenario script supplies the per-scenario state referenced as scenario. For each scenario, the result reports a textual rendering of a successful non-void return value in resultText, captured logs, or an error.

The model chooses whether to call the tool and which scenarios to try. Mock execution therefore provides evidence but does not establish coverage or decide whether the script is accepted.

Sample Inspection

Sample inspection projects objects supplied by the application through the schema so the model can examine representative values, shapes, and conventions. Each sample receives a stable name derived from its schema type, such as ticket0, ticket1, and ticket2.

Inside the authoring loop, the model can call inspect_samples with a guest-language probe. Application developers and end users do not construct this tool call:

Model call to the inspect_samples tool
{
  "source": "for (const name of ['ticket0', 'ticket1', 'ticket2']) { const ticket = samples[name]; console.log(`${name}: outage=${ticket.outage}, billing=${ticket.billing}`); }",
  "maxOutputLines": 20
}

The tool returns the captured output as structured feedback:

{
  "kind": "sample_object_inspection",
  "status": "success",
  "outputLines": [
    "ticket0: outage=true, billing=false",
    "ticket1: outage=false, billing=true",
    "ticket2: outage=false, billing=false"
  ],
  "truncated": false,
  "lineLimit": 20,
  "totalLines": 3
}

Configure sample inspection with AuthoringTools.Builder.sampleInspection(…​) when a generic schema needs representative domain examples, such as property names, typical scalar values, ordering, or application conventions.

Samples are authoring-time evidence. They do not alter the schema, populate mock execution, or become available to the completed script.

Sample values and inspection results can become visible to the model provider. Prefer synthetic, immutable, or sanitized objects, and do not expose methods or data that are unsafe to inspect.

Completion Tests

Completion tests are host-defined acceptance checks configured through AuthoringTools.Builder.testExecution(…​). When the model calls script_done, Graal Script Agent binds the candidate script and runs every registered test. If the schema declares no script properties, the internal tool call has an empty argument object:

Model call to the script_done tool
{}

If a test fails, script_done returns structured feedback instead of completing the authoring run:

{
  "kind": "script_done",
  "status": "tests_failed",
  "message": "Script tests failed. Fix the draft and call script_done again.",
  "summary": {
    "passed": 1,
    "failed": 1,
    "errored": 0,
    "total": 2
  },
  "tests": [
    {
      "name": "outages take precedence",
      "status": "fail",
      "message": "outage must route to INCIDENT",
      "guestStack": []
    },
    {
      "name": "unmatched tickets use the fallback",
      "status": "pass",
      "guestStack": []
    }
  ]
}

The model can then edit the draft and call script_done again. Completion succeeds only when every test passes. If the model determines that it cannot satisfy the tests, it can call script_failed; generation then fails without returning a script.

Each named test receives a fresh binding in a fresh context created by the selected sandbox. Guest state is not shared between tests. Assertion failures are reported as failed tests; execution exceptions are reported as errors.

The configured tests are authoring-time checks. They are not stored in Script<T> and do not run automatically when a completed script is later restored or executed.

Manage the Schema Prompt Budget

AuthoringTools.Builder.promptTokenLimit(int) limits the estimated size of the rendered system prompt and defaults to 10,000 tokens. This guards against unexpectedly large model requests and their cost. If the required prompt cannot fit within the limit, script generation fails before making a model request.

AuthoringTools tools = AuthoringTools.newBuilder()
        .mockExecution(Sandbox.UNTRUSTED)
        .promptTokenLimit(8_000)
        .build();

When the complete schema rendering does not fit, Graal Script Agent replaces deeper type details with an explicit marker. For example, a larger routing schema could contain:

/* omitted type CustomerHistory; inspect its rendered detail before using members, overloads, fields, traits, constructors, or helper surface */

Before using that type, the model can call schema_detail_request:

{
  "elementId": "CustomerHistory"
}

The result contains the full language-specific rendering of CustomerHistory. The model can then use its members without requiring the entire schema to be present in the initial prompt.

The target extension type and required authoring instructions are never silently removed. If required content still cannot fit, authoring fails before making a model request. Increase the budget or simplify the schema in that case.

Implementing a custom AuthoringTool is covered in the advanced topics. See Advanced Topics.

Execute Scripts Safely

A completed Script<T> is a reusable artifact, not a running guest program. To execute it, create a Polyglot Context, bind the script to that context, and invoke the returned Java interface.

Binding and invocation are local operations. They do not contact the authoring model or resume the authoring session.

Bind and Invoke a Script

The following example uses the Router extension point from Getting Started:

try (Context context =
        Sandbox.UNTRUSTED.newContext(script.language())) {

    Router router = script.bind(context);

    Queue outage =
            router.route(new Ticket(true, false));
    Queue billing =
            router.route(new Ticket(false, true));
}

Script.bind(Context) evaluates the guest source in the supplied context and returns an implementation of the script’s target Java interface. The application owns the context and must close it.

The bound implementation remains associated with that context. Do not invoke or retain it after the context is closed.

Bind a script once per context and reuse the returned interface. Calls through that interface share guest state. When scripts or executions should not share guest state, create a fresh context and binding for each. Separate contexts normally keep guest state apart, but shared host objects or other process-wide state can still connect them.

Choose a Sandbox

Treat generated source as having the same trust level as the prompt and other input that shaped it. Use Sandbox.UNTRUSTED whenever that input can be influenced by someone who is not trusted to supply executable code, such as an ordinary user of a hosted application. Sandbox.CONSTRAINED is appropriate only when everyone controlling the script is trusted to run code in the host environment and accidental bugs, rather than hostile code, are the threat. One example is a single-user desktop application whose user already controls that environment.

Preset Intended use

Sandbox.TRUSTED

Fully trusted guest source. It has no preset Script Agent resource limits.

Sandbox.CONSTRAINED

Trusted guest source that may be buggy. It restricts host and system access and applies CPU and output limits, but the guest and host share the same virtual machine.

Sandbox.ISOLATED

Trusted guest source that may process untrusted input. It separates guest and host virtual machines and applies isolate-memory, CPU, and output limits.

Sandbox.UNTRUSTED

Guest source that may itself be malicious. It uses the strongest policy and adds limits for isolate memory, script memory, CPU time, AST depth, guest threads, and output.

See the GraalVM sandboxing guide for the security guarantees and runtime requirements of each policy.

The predefined sandboxes provide starting limits. Derive a custom sandbox with Sandbox.newBuilder(Sandbox) when the application needs different bounds:

Sandbox sandbox = Sandbox.newBuilder(Sandbox.UNTRUSTED)
        .maxCpuTime(Duration.ofSeconds(10))
        .maxScriptMemory(64L * 1024 * 1024)
        .maxOutputStreamSize(64L * 1024)
        .maxErrorStreamSize(64L * 1024)
        .build();

See Set Up Your Project for the JavaScript and Python runtime artifacts required by each sandbox mode.

Understand the Execution Boundary

A sandbox is one layer of the execution boundary:

Layer Responsibility

Schema

Declares the types and operations visible to the guest script.

HostBinding

Projects host values, dispatches schema-declared operations, and converts values across the boundary.

Sandbox

Restricts guest access to the runtime, host system, and configured resources.

Application

Determines which extension point may run, which host objects it receives, and whether its effects are permitted.

The schema is the allowlist for host values projected through Script Agent’s binding boundary. Public Java members that are not represented by schema elements or traits remain inaccessible through that projection.

A sandbox cannot make a dangerous exposed host operation safe. Keep host APIs narrow and enforce application policy independently of guest isolation.

Expose only read-only or reliably reversible host operations for direct execution. Never give a generated script direct access to irreversible operations. For state-changing workflows, follow Apply Destructive Operations Safely so requested changes remain under application control.

Graal Script Agent includes built-in JavaScript and Python language adapters. It injects schema-derived types and language utilities when the script is bound.

Scripts are designed to be self-contained and run without external guest libraries. Bind them to an otherwise unmodified context created by the selected Sandbox; do not enable package loading or filesystem access, and do not inject ad hoc host globals. Expose application interaction exclusively through schema-backed host APIs.

Handle Execution Failures

Failures can occur while creating the context, binding the source, or invoking the returned interface:

Failure Examples

Runtime or configuration

Missing guest-language runtime, unsupported sandbox options, or incompatible context configuration.

Binding

Missing HostBinding, invalid guest source, an invalid root value, or a conversion failure.

Guest execution

A JavaScript or Python exception raised while evaluating or invoking the script.

Host execution

An exception raised by a schema-declared host operation.

Resource exhaustion

CPU, memory, output, statement, AST-depth, or guest-thread limits.

Guest, host, and resource failures may surface as PolyglotException:

try {
    return router.route(ticket);
} catch (PolyglotException error) {
    if (error.isHostException()) {
        Throwable hostCause = error.asHostException();
        // Host-side binding, conversion, or application code failed.
    } else if (error.isResourceExhausted()
            || error.isCancelled()
            || error.isExit()
            || error.isInternalError()) {
        // Discard this context and create a new one.
    } else {
        // Guest invocation failed.
    }
    throw error;
}

After resource exhaustion, cancellation, guest exit, or an internal error, treat the context as disposable. Do not return it to a pool or continue invoking interfaces bound to it.

Apply Destructive Operations Safely

Generated scripts can decide which changes to request, but trusted application code must decide whether and how those changes are applied.

For a state-changing extension point, expose a recording API rather than a live mutation API. Its methods record typed operations without changing application state. After the script finishes and its guest context is closed, the application validates the recorded operations and follows one of two review workflows.

Choose When to Review

Operation Example Workflow

Reliably reversible

A local ticket queue change with a dependable host-implemented Undo operation

Apply immediately, then show the user what changed and provide Undo or Revert.

Irreversible or uncertain

A hard deletion, lossy overwrite, payment, notification, webhook, or operation with uncertain rollback

Show what will happen first, then require explicit human approval before applying it.

The host application classifies each operation. Generated code must not classify its own changes as reversible. If a group contains any irreversible or uncertain operation, review the entire group before execution.

Treat database changes cautiously. Rolling back an open transaction is different from undoing a committed user operation. Cascades, concurrent updates, dependent data, and external effects often make complete restoration difficult. If the application cannot provide a dependable Undo operation, use review before execution.

Persist and Restore Scripts

A completed Script<T> is a reusable artifact. Persist it once, restore it when the application needs it, and execute it locally without another model call.

For extension points defined by Java APIs, persist the script JSON and reconstruct the schema from the Java interface when loading. Persist the schema separately only when using a custom schema that cannot be reconstructed conveniently.

Persist a Script

Serialize a completed script with Script.toJson():

String scriptJson = script.toJson();

Store the resulting JSON in the application’s database, object store, or other durable storage. It contains:

  • the guest-language source and source metadata;

  • the language identifier;

  • schema-declared script properties; and

  • the schema fingerprint.

It does not contain the target Java interface or the full schema. The application supplies both when restoring the script.

Restore Against the Java API

For a Java extension point, rediscover the schema from the current interface and pass it to Script.fromJson(Class, String, Schema):

Schema schema = JavaDiscovery.discover(Router.class);

Script<Router> restored =
        Script.fromJson(Router.class, scriptJson, schema);

The discovered schema includes the reflection-backed host binding, so the restored script can be bound and executed normally.

The script stores the schema fingerprint used during authoring. Script.fromJson(…​) requires an exact match with the supplied schema and rejects restoration when the Java API’s schema has changed.

Continue or Migrate a Persisted Script

When the schema still matches, restore the script and use ScriptAgent.revise(Script, String, AuthoringTools) to continue authoring from its current source:

Script<Router> revised = agent.revise(
        restored,
        "Keep the current behavior, but route urgent billing tickets to PRIORITY.",
        tools);

revise(…​) targets the existing script’s schema. It is appropriate for behavioral changes while the extension point remains unchanged.

When the Java API’s schema has changed, create a replacement against the current interface. Pass the persisted script JSON as context so the model can adapt its previous implementation:

Script<Router> migrated = agent.generate(
        Router.class,
        """
        Adapt this persisted router to the current extension point.
        Preserve its existing behavior where the current contract permits it.

        Persisted script:
        %s
        """.formatted(scriptJson),
        tools);

This is a new generation against the current schema, not a call to revise(…​). Use the current authoring tools and acceptance tests to validate the migrated behavior, then persist the replacement script after generation succeeds.

Understand the Persistence Boundary

Script persistence stores the completed source and its properties. It does not preserve a bound interface, Polyglot context, guest runtime state, authoring session, model conversation, or authoring-time tests. Create a suitable sandboxed context for each runtime lifecycle, and configure tests again when revising or migrating a script.

Persisted scripts remain untrusted executable code. Protect who may store and execute them, and follow Execute Scripts Safely whenever a restored script is bound.

Observe Script Authoring

Graal Script Agent emits structured lifecycle events while a model generates or revises a script. Use them to show progress, update an application UI, collect metrics, or diagnose a failed authoring run.

These events cover model-backed authoring through generate(…​), revise(…​), and Session.generate(…​). They do not instrument local invocation of a bound script; observe runtime behavior through the application’s normal telemetry.

API Use it for Content

ScriptAgent.Builder.traceStatus(OutputStream)

User-facing progress and concise operational logs

Built-in, host-controlled milestones that omit prompts, model output, tool arguments, and generated source

ScriptAgent.Builder.eventListener(Consumer)

Application integrations, metrics, and custom presentation

Typed events ranging from lifecycle markers to potentially sensitive request, response, and tool data

ScriptAgent.Builder.traceEvents(OutputStream)

Detailed diagnostics and offline analysis

The complete normalized event stream as sensitive JSONL

Show Concise Progress

Add traceStatus(…​) to the agent builder to write progress for every authoring run created by that agent:

try (ScriptAgent agent = ScriptAgent.newBuilder(model)
        .language("js")
        .traceStatus(System.out)
        .build()) {
    Script<Router> script = agent.generate(Router.class, prompt, tools);
}

A simple run might produce:

Generating js script
Step 1: writing script
Step 1: wrote 12 lines
Step 1: finalizing script
Step 1: script finalized
Script generation complete in 2.4 s

The exact milestones depend on the authoring tools and whether the run retries or fails. Routine model and streaming activity is omitted. Built-in tool messages describe host-observed work rather than rendering model-provided tool names or arguments.

Custom authoring tools may supply their own status messages. The status trace writes them as supplied after format validation, so they can expose sensitive content if the tool includes it. Treat status output as sensitive unless every custom status message has been reviewed.

Consume Lifecycle Events

Register eventListener(…​) when the application needs structured updates:

ScriptAgent agent = ScriptAgent.newBuilder(model)
        .eventListener(event -> {
            switch (event) {
                case ScriptAgent.RunStart start ->
                    System.out.println("Started " + start.runId());
                case ScriptAgent.ToolExecutionEnd tool ->
                    System.out.println(tool.toolName() + ": " +
                            (tool.error() ? "failed" : "completed"));
                case ScriptAgent.RunEnd end ->
                    System.out.println("Succeeded: " + end.success());
                default -> {
                }
            }
        })
        .build();

One uninterrupted run follows this lifecycle:

run_start
  turn_start
    message_start
    message_update*
    message_end
    (tool_execution_start
     tool_execution_end)*
  turn_end
  ...
run_end

A retry emits turn_end with a retry outcome, followed by retry and another attempt. Normal failures still close the message, turn, and run. A listener failure is different: it aborts dispatch immediately, so later closing events are not guaranteed.

Every event provides schemaVersion(), type(), runId(), sequence(), and timestamp(). ScriptAgent.TurnEvent additionally provides zero-based attempt() and step() values.

Use the fields as follows:

  • runId is new for every generation or revision. Separate turns of the same authoring session still receive separate run IDs.

  • sequence increases from zero within one run. The pair (runId, sequence) identifies an event.

  • attempt changes when the complete authoring run is retried.

  • step identifies one model-and-tool step within an attempt.

  • Tool events additionally expose the tool-call index, ID, and name for matching start and end events.

ScriptAgent.MessageUpdate reports streamed text, thinking, and tool-call updates when the model adapter provides them. Streaming updates are observational. Only the completed model response is authoritative, and tool execution begins after ScriptAgent.MessageEnd.

Event classes and discriminator values may grow over time. Include a default branch when switching over events, and tolerate fields or update types that the application does not recognize.

Scope Listeners Correctly

Listeners configured on ScriptAgent.Builder observe every run created by that agent. Add listeners or traces to ScriptAgent.SessionBuilder when they should observe only that session:

try (ScriptAgent.Session<Router> session =
        agent.newSessionBuilder(Router.class, schema)
                .eventListener(event ->
                        System.out.println(event.type()))
                .traceStatus(System.out)
                .build()) {
    session.generate(prompt, tools);
}

Registrations are additive. Agent listeners run first, followed by session listeners, and registration order is preserved within each scope.

Event delivery is synchronous. Callbacks run on the thread dispatching the event, and independent sessions may emit concurrently. Keep shared listener state thread-safe and keep callbacks short; a blocking listener also blocks authoring progress. Do not call Session.generate(String) or Session.clear() on the same session from one of its listeners.

A listener exception aborts the active authoring run. Catch failures inside a listener when telemetry should be best effort, or enqueue the immutable event for asynchronous processing and return promptly.

Capture a Diagnostic Trace

Use traceEvents(…​) when detailed diagnostics are required:

Path tracePath = Path.of("authoring-events.jsonl");

try (OutputStream trace = Files.newOutputStream(tracePath);
        ScriptAgent agent = ScriptAgent.newBuilder(model)
                .traceEvents(trace)
                .build()) {
    agent.generate(Router.class, prompt, tools);
}

The output is UTF-8 JSONL with one normalized event object per line. Each object starts with the event schema version, type, run ID, sequence, and timestamp, followed by fields specific to that event. Output is flushed after every line. Consumers should use schemaVersion and tolerate unknown event types and fields.

Diagnostic traces can contain system and user prompts, model responses and thinking, tool arguments and results, sample data, and complete generated source. They are not a privacy or security boundary. Restrict access, retention, and export according to the most sensitive application data that authoring can expose.

Both tracing methods add synchronous listeners and do not close the supplied OutputStream. The application owns the stream. A trace write failure aborts authoring just like any other listener failure.

Build a Native Image

Graal Script Agent’s core features are available in native executables: authoring, revision, persistence, and execution with JavaScript or Python and every sandbox mode. The chosen model implementation and its dependencies must also support Native Image.

Native Image requires reflection and proxy metadata for Java objects used through Polyglot host interoperability. With default Java discovery, Graal Script Agent generates it automatically: annotate each reachable extension-point root with @DefaultSchemaDiscovery. Custom discovery is covered below.

Configure the Native Build

Use the standard GraalVM Native Build Tools integration for your build system:

Follow those guides for plugin configuration, executable entry points, build arguments, and native test support. The scriptagent JAR enables its Native Image feature automatically, so no --features option or separate Script Agent build-tool plugin is required. Keep the Script Agent, model connector, and guest-language dependencies described in Set Up Your Project.

Register Default Java Discovery

For the normal JavaDiscovery workflow, annotate each extension-point root with @DefaultSchemaDiscovery. For example, add the annotation to the Router from Getting Started:

@DefaultSchemaDiscovery
public interface Router {
    Queue route(Ticket ticket);
}

Continue discovering and using the schema normally:

Schema schema = JavaDiscovery.discover(Router.class);

During image analysis, the Script Agent feature performs the same default discovery for Router. It registers the schema’s Java classes and bound fields, methods, and constructors for reflection. It also registers the interface proxies required to bind generated implementations. Records, enums, nested types, and sealed hierarchies reached from the root are included automatically. For these registered schemas, do not maintain reflect-config.json or proxy-config.json entries.

Annotate only the discovery root, not every type in its schema. Annotate each root separately when an application has multiple extension points.

The annotated root must be reachable to Native Image. Passing Router.class to JavaDiscovery, ScriptAgent.generate(…​), or another runtime path normally establishes that reachability. The annotation does not retain an otherwise unused extension point.

@DefaultSchemaDiscovery always corresponds to JavaDiscovery.discover(Class): it uses the public lookup and JavaDiscoveryConfig.DEFAULT. Use a provider when runtime discovery uses a custom lookup or configuration.

Register Custom Java Discovery

Implement SchemaReflection when the application customizes Java discovery. Its contribute(SchemaReflectionRegistry) callback runs at image build time and contributes each schema to the registry.

Keep schema construction in one place so build-time registration and runtime discovery cannot drift. This example uses a package-scoped discovery boundary and a lookup with access to the application package:

public final class RoutingSchemas {
    private static final JavaDiscoveryConfig CONFIG =
            JavaDiscoveryConfig.newBuilder()
                    .includeClassesMatching(type ->
                            type.getPackageName().equals("com.example.routing"))
                    .build();

    private RoutingSchemas() {
    }

    public static Schema router() {
        return JavaDiscovery.discover(
                Router.class, MethodHandles.lookup(), CONFIG);
    }
}

Use that same method from the application and the build-time provider:

public final class ApplicationSchemaReflection implements SchemaReflection {
    @Override
    public void contribute(SchemaReflectionRegistry registry) {
        registry.register(RoutingSchemas.router());
    }
}

On the class path, register the provider through ServiceLoader.

src/main/resources/META-INF/services/org.graalvm.scriptagent.nativeimage.SchemaReflection
com.example.routing.ApplicationSchemaReflection

In a named module, declare the provider in module-info.java instead:

provides org.graalvm.scriptagent.nativeimage.SchemaReflection
        with com.example.routing.ApplicationSchemaReflection;

A provider may call SchemaReflectionRegistry.register(Schema) more than once. Register every schema variant that the executable can select at runtime. The provider contributes only Native Image metadata; the application still constructs its schema normally when it authors, restores, or executes a script.

See the org.graalvm.scriptagent.nativeimage package for the complete API.

Advanced Topics

Beyond application-defined extension points, Graal Script Agent exposes advanced APIs for customizing schemas, host access, authoring tools, model integration, and guest languages. These APIs are intended for advanced users and are still evolving. This chapter is deliberately brief while their documentation matures; consult the linked API reference for the complete contracts.

Build Dynamic Schemas and Host Bindings

Prefer the static generic schema approach described in Design Extension Points. Build a dynamic schema only when the contract itself must vary at runtime and representative samples cannot express that variation. More complete guidance for dynamic schemas is planned for a future release.

For now, construct the contract with Schema and its builders. Implement HostBinding to map the declared schema elements to host operations, then attach it through Schema.Builder.hostBinding(HostBinding) or supply it through Script.bind(Context, HostBinding). Keep the schema and binding aligned: the schema is the complete guest-visible surface, and raw Polyglot Value objects must not cross the binding API.

Implement PersistentHostBinding only when a serialized custom schema must retain binding metadata. For Native Image, account separately for any metadata required by the custom binding; @DefaultSchemaDiscovery covers default Java discovery, as described in Build a Native Image.

Implement a Custom Authoring Tool

The built-in authoring tools cover normal generation and revision. Implement AuthoringTool only when the model needs application-specific evidence. This example exposes the current queue precedence as structured feedback:

final class RoutingPolicyTool implements AuthoringTool {
    @Override
    public String kind() {
        return "routing_policy";
    }

    @Override
    public String description(ToolSchema context) {
        return "Return the current queue precedence.";
    }

    @Override
    public JsonNode parametersSchema(ToolSchema context) {
        return JsonNodeFactory.instance.objectNode()
                .put("type", "object")
                .put("additionalProperties", false);
    }

    @Override
    public Result execute(ToolContext context, JsonNode arguments) {
        return new ContinueResult(
                "{\"precedence\":[\"INCIDENT\",\"BILLING\",\"GENERAL\"]}");
    }
}

AuthoringTools tools = AuthoringTools.newBuilder()
        .tool(RoutingPolicyTool::new)
        .build();

AuthoringTools.Builder.tool(Supplier) creates a separate tool instance for each authoring run, and Graal Script Agent invokes AuthoringTool.close() afterward. Validate any arguments the tool uses, minimize model-visible application data, and return narrow, actionable results.

This extension point is still evolving. If you have an authoring-tool use case that the current API does not serve well, open an issue and share it with the project.

Implement a Custom Model Provider

Implement Model to adapt another provider client or create a deterministic model for integration tests. Its Model.generate(ModelRequest, ModelEventSink) method receives each complete request. Follow the contracts of ModelRequest, ModelResponse, and ModelEventSink, then pass the implementation to the agent:

Model model = new MyModelAdapter();
ScriptAgent agent = ScriptAgent.newBuilder(model)
        .language("js")
        .build();

Implement a Custom LanguageSupport

Implement LanguageSupport to add another Polyglot guest language or different language conventions. Its rendering methods use PromptBuilder to describe the schema, while LanguageSupport.bind(…​) evaluates source and exposes the runtime roots using the same conventions. Select it with ScriptAgent.Builder.language(LanguageSupport) during authoring and pass it to Script.bind(Context, LanguageSupport) during execution.

Example Extensions

For a compact walkthrough, start with Getting Started. These larger examples show Script Agent integrated into applications.

  • Spring Boot PetClinic — Adds prompt-authored owner queries and reviewed data modifications to the classic PetClinic application. Owner-list queries can become reusable searches. The example combines sessions, persisted scripts, and sandboxed execution.

  • Micronaut PetClinic — Implements the same workflows in Micronaut, including interactive refinement, saved queries, modification previews, and Native Image deployment.

  • Winamp Audio Visualizer — Generates a JavaScript or Python pixel renderer from a prompt, then invokes it locally for every frame using bounded audio data. The Swing demo requires a graphical desktop.

Using Graal Script Agent in a project with publicly available source? Send us a pointer.

Current Limitations

Graal Script Agent currently has three main limitations.

Early-Preview API Stability

Graal Script Agent is an early-preview library. Its public Java APIs and authoring workflows may change incompatibly between preview releases. Applications should pin the Script Agent version and evaluate the required source and integration changes before upgrading.

Java-Only Built-In Discovery

JavaDiscovery is currently the only built-in API-discovery implementation. It uses Java reflection and does not provide Kotlin- or Scala-aware discovery.

This limits automatic discovery, not the rest of Script Agent. An application can support another JVM language by using the public Schema and HostBinding APIs to produce an aligned script-visible contract and runtime binding. Authoring and execution then use those artifacts without needing to understand the source JVM language. Contributions that add built-in discovery for other JVM languages are welcome; see the contribution guide.

No Automatic Authoring-History Management

A stateful authoring session retains and replays the complete transcript of every successful turn. It does not automatically compact, truncate, or summarize that history. Long-lived sessions therefore consume progressively more model context, increasing latency and cost until they may reach the provider’s context-window limit.

Keep sessions scoped to one authoring task. Session.clear() discards both retained history and the current script. To preserve current work without its conversational history, retain or persist the latest Script<T> and start a new revision or session from that artifact. See Generate and Revise Scripts for session guidance.

This limitation will likely be lifted in the future.

Troubleshooting

Authoring Ends Without a Script

ScriptAgent.generate(Class, String), ScriptAgent.revise(Script, String), and ScriptAgent.Session.generate(String) can throw ScriptAuthoringException when the model reports that it cannot implement the request or the authoring loop reaches its configured step bound without completing a script.

Catch this exception at the application boundary and present its message to the requesting user as plain text so they can revise the request. Keep any previous script unchanged, log the exception for diagnosis, and never execute an unfinished draft.

Schema Details Cannot Fit the Prompt Budget

Authoring stops before a model request if the complete system prompt exceeds AuthoringTools.Builder.promptTokenLimit(int) and schema_detail_request is unavailable. Without that tool, Graal Script Agent cannot safely omit schema type details because the model could not retrieve them later.

Standard AuthoringTools configurations include schema_detail_request. This error therefore indicates an unexpected custom or incompatible tool configuration. Check that configuration, increase promptTokenLimit, or expose a smaller schema.

The Selected Sandbox Is Not Supported

Use Sandbox.isSupported() before offering a sandbox configuration. If it returns false, creating a context or engine with that sandbox throws IllegalStateException. Use GraalVM JDK 25 or add the matching Polyglot Isolate guest-language dependency described in Set Up Your Project.

On a runtime without sandbox support, only Sandbox.TRUSTED without resource limits is available. Do not silently downgrade generated or otherwise untrusted source to that mode; reject the operation until an appropriate runtime is available. See Execute Scripts Safely for choosing a sandbox from the source and input trust boundaries.

The Runtime Uses the Interpreter-Only Fallback

On OpenJDK or Oracle JDK with Polyglot 25.1 or later, the engine can report that its fallback runtime does not support runtime compilation to machine code. The script can still run, but repeated guest execution may be significantly slower. Use the recommended GraalVM JDK 25 when runtime optimization matters.

For intentionally small workloads, the warning can be hidden with -Dpolyglot.engine.WarnInterpreterOnly=false or the corresponding engine.WarnInterpreterOnly=false engine option. Suppressing it does not enable optimization. See the GraalVM runtime optimization warning documentation for the supported runtime configurations and remedies.

Glossary

Term Description

Host

The Java application that embeds Graal Script Agent, defines extension points, and provides explicitly exposed data and operations.

Guest

The generated JavaScript or Python code that executes inside a GraalVM polyglot context.

Extension point

A narrow application-defined Java contract that a generated script implements.

Schema

The canonical nominal description of the types, members, properties, and capabilities visible to guest code.

HostBinding

The host-owned mapping from schema-declared operations to Java values and behavior at execution time.

Script

A typed executable artifact containing generated guest source, script properties, and the schema fingerprint required for safe restoration.

Authoring

The model-assisted process that generates or revises a script against an extension-point schema.

Execution

Local evaluation and invocation of an authored script without another model request.

Model

The provider-independent interface used by Graal Script Agent to exchange typed messages and tool calls during authoring.

Sandbox

The policy that configures guest permissions, resource limits, and other restrictions for a polyglot context.

Polyglot context and engine

A context is the isolated container in which guest code executes; an engine may share runtime and compiled-code state across multiple contexts.

AuthoringTool

A bounded tool that the model may invoke to inspect, edit, execute, or validate the current draft during authoring.