codeact
In-process JS/Python execution for Go LLM agents. No Cgo. No subprocesses.
Powered by Ramune (JavaScriptCore or QuickJS) and pyffi (CPython).
Why codeact?
Existing Go AI agent frameworks (Eino, Google ADK Go, LangChainGo, etc.) execute code via subprocesses — spawning python3 or node with os/exec. The subprocess cannot call back into your Go application.
This forces multi-turn LLM conversations:
── Subprocess approach ──────────────────────────────
Turn 1: LLM → tool_call: get_data("users") → Go function → result
Turn 2: LLM → tool_call: execute_python("sort…") → subprocess → result
⚠ execute_python cannot call get_data()
Turn 3: LLM → tool_call: save_result(sorted) → Go function → done
Total: 3 LLM turns
codeact embeds the runtime in-process. RegisterFunc exposes Go functions directly to the script. The LLM writes one program that mixes Go function calls with computation:
── codeact ──────────────────────────────────────────
Turn 1: LLM → execute_js(`
var data = get_data("users"); // call Go
data.sort((a, b) => a.age - b.age); // compute in JS
save_result(data); // call Go again
`) → done
Total: 1 LLM turn
Fewer LLM round-trips means faster, cheaper, and less error-prone execution.
Quick Start
The agent gets an execute_js tool and explicitly writes code that calls your Go functions.
runner, _ := js.NewRunner()
defer runner.Close()
runner.RegisterFunc("getUsers", func(args []any) (any, error) {
return db.QueryUsers(ctx)
})
result, _ := runner.Run(ctx, `
const users = getUsers();
return users.filter(u => u.active).length;
`)
fmt.Println(result.Result) // "42"
AI Functions — describe what you want, LLM writes the code
Write the desired behavior in natural language. The agent calls analyze_users(minAge: 25) like any regular tool — behind the scenes, a separate LLM call generates the implementation from your description.
spec := &aifunc.Spec{
Name: "analyze_users",
// Describe what the function should do — the LLM generates the code
Description: "Filter users by minimum age, sort by signup date, " +
"and return a JSON object with totalCount and averageAge fields",
Params: []aifunc.ParamSpec{
{Name: "minAge", Type: "number", Description: "Minimum age filter"},
},
GoFuncs: []aifunc.GoFuncSpec{
{Name: "getUsers", Fn: getUsersFunc, Signature: aifunc.InferSignature(getUsersFunc)},
},
PostConditions: []string{"result.totalCount >= 0"},
}
model := einoadapter.WrapModel(chatModel)
tool, _ := einoadapter.NewAIFunc(runner, model, spec)
// The outer agent sees "analyze_users(minAge)" — a structured tool like any other.
// Internally: description → LLM generates code → code calls getUsers() → result
Install
# Runtimes (pick one or both)
go get github.com/i2y/codeact/js # JavaScript/TypeScript
go get github.com/i2y/codeact/python # Python
# Framework adapters (pick what you use)
go get github.com/i2y/codeact/eino # Eino
go get github.com/i2y/codeact/adk # Google ADK
go get github.com/i2y/codeact/langchain # LangChainGo
go get github.com/i2y/codeact/blades # Blades
RegisterFunc
Expose any Go function to LLM-generated code.
JavaScript
JS uses ramune.GoFunc signature: all arguments arrive as []any.
runner, _ := js.NewRunner()
runner.RegisterFunc("queryDB", func(args []any) (any, error) {
sql, _ := args[0].(string)
return db.Query(ctx, sql)
})
runner.RegisterFunc("sendSlack", func(args []any) (any, error) {
channel, _ := args[0].(string)
msg, _ := args[1].(string)
return nil, slackClient.Post(channel, msg)
})
// LLM-generated code
const users = queryDB("SELECT name FROM users WHERE active = true");
const summary = users.map(u => u.name).join(", ");
sendSlack("#reports", `Active users: ${users.length} — ${summary}`);
RegisterAnyFunc accepts arbitrary Go function signatures and handles type conversion via reflection:
runner.RegisterAnyFunc("add", func(a, b int) int { return a + b })
Python
Python accepts any Go function signature directly. Functions are called via go_bridge.
runner, _ := python.NewRunner()
runner.RegisterFunc("queryDB", func(sql string) ([]map[string]any, error) {
return db.Query(ctx, sql)
})
import go_bridge
rows = go_bridge.queryDB("SELECT * FROM users WHERE active = 1")
print(f"Active users: {len(rows)}")
Framework Adapters
All adapters accept the generic Runner interface.
Eino
import (
"github.com/i2y/codeact/js"
einoadapter "github.com/i2y/codeact/eino"
)
jsRunner, _ := js.NewRunner()
jsTool := einoadapter.NewTool(jsRunner)
agent, _ := react.NewAgent(ctx, &react.AgentConfig{
Model: chatModel,
Tools: []tool.BaseTool{jsTool},
})
Google ADK
import adkadapter "github.com/i2y/codeact/adk"
jsTool, _ := adkadapter.NewTool(jsRunner)
LangChainGo
import lcadapter "github.com/i2y/codeact/langchain"
jsTool := lcadapter.NewTool(jsRunner)
Blades
import bladesadapter "github.com/i2y/codeact/blades"
jsTool := bladesadapter.NewTool(jsRunner)
AI Functions
Define tools in natural language instead of writing implementation code. You describe what the function should do, list the Go functions it can call, and specify post-conditions — the LLM generates and executes the code at runtime. Think Strands AI Functions for Go.
import (
"github.com/i2y/codeact/js"
"github.com/i2y/codeact/aifunc"
einoadapter "github.com/i2y/codeact/eino"
)
runner, _ := js.NewRunner()
spec := &aifunc.Spec{
Name: "analyze_logs",
Description: "Analyze server logs and detect anomalies",
Params: []aifunc.ParamSpec{
{Name: "timeRange", Type: "string", Description: "Time range like '1h', '24h'"},
{Name: "severity", Type: "string", Description: "Minimum severity: 'info', 'warn', 'error'"},
},
GoFuncs: []aifunc.GoFuncSpec{
{
Name: "queryLogs",
Fn: queryLogsFunc,
Signature: aifunc.InferSignature(queryLogsFunc),
},
{
Name: "getMetrics",
Fn: getMetricsFunc,
Signature: aifunc.InferSignature(getMetricsFunc),
},
},
PostConditions: []string{
"result.anomalyCount >= 0",
"result.checkedEntries > 0",
},
}
// Wrap your framework's model as aifunc.Model
model := einoadapter.WrapModel(chatModel)
// Use as an Eino tool alongside regular CodeAct tools
analyzeTool, _ := einoadapter.NewAIFunc(runner, model, spec)
execTool := einoadapter.NewTool(runner)
agent, _ := react.NewAgent(ctx, &react.AgentConfig{
Model: chatModel,
Tools: []tool.BaseTool{analyzeTool, execTool},
})
When the LLM calls analyze_logs(timeRange: "1h", severity: "error"), the flow is:
- System prompt is built from the spec (task description, parameter definitions, available Go functions, post-conditions)
- LLM generates code that calls
queryLogs() and getMetrics() to fulfill the request
- Code is executed on the runner
- Post-conditions are validated — if any fail, the LLM gets error feedback and retries (up to
MaxRetries)
- Result is returned to the outer agent
Each adapter provides NewAIFunc(runner, model, spec) and WrapModel to convert the framework's LLM type to aifunc.Model:
// Eino — wraps model.BaseChatModel
model := einoadapter.WrapModel(chatModel)
tool, err := einoadapter.NewAIFunc(runner, model, spec)
// Google ADK — wraps model.LLM
model := adkadapter.WrapModel(llm)
tool, err := adkadapter.NewAIFunc(runner, model, spec)
// Blades — wraps blades.ModelProvider
model := bladesadapter.WrapModel(provider)
tool, err := bladesadapter.NewAIFunc(runner, model, spec)
// LangChainGo — provide your own aifunc.Model implementation
tool, err := lcadapter.NewAIFunc(runner, model, spec)
You can also implement aifunc.Model directly for custom LLM clients:
type Model interface {
Generate(ctx context.Context, systemPrompt string, messages []aifunc.Message) (string, error)
}
Runtimes
JavaScript/TypeScript (Ramune)
runner, _ := js.NewRunner(
js.WithDependencies("cheerio@1", "xlsx@0.18"),
js.WithTimeout(10 * time.Second),
js.WithPermissions(&ramune.Permissions{
Net: ramune.PermGranted, // allow fetch
Read: ramune.PermGranted, // allow file reads
Write: ramune.PermDenied, // deny file writes
Run: ramune.PermDenied, // deny process execution
}),
)
- JSC backend (default): macOS (bundled), Linux (requires
libjavascriptcoregtk-4.1-dev)
- QuickJS backend: build with
-tags quickjs — pure Go, works everywhere including Windows
- Top-level
await supported. Code is wrapped in an async IIFE
- Last expression value is returned as
RunResult.Result
console.log/error/warn/info captured in RunResult.Logs
Python (pyffi/CPython)
runner, _ := python.NewRunner(
python.WithDependencies("pandas", "requests"), // installed via uv
python.WithTimeout(10 * time.Second),
)
- Requires CPython 3.9+
WithDependencies requires uv
- Assign to
__result for explicit return, or the last expression is returned
print() output captured in RunResult.Logs
- No permission sandbox — full CPython access
Runner Interface
type Runner interface {
Run(ctx context.Context, code string) (*RunResult, error)
Close() error
Language() string // "javascript" or "python"
ToolName() string // "execute_js" or "execute_python"
ToolDescription() string
}
type RunResult struct {
Result string `json:"result"`
Logs []string `json:"logs,omitempty"`
Error string `json:"error,omitempty"`
}
Code exceptions are captured in RunResult.Error, not returned as Go errors. This lets the LLM observe the error and retry. Only infrastructure failures (runtime crash, etc.) return Go errors.
Architecture
codeact/ Core interface (Runner, RunResult)
├── codeact/aifunc AI Functions core (Spec, Execute, prompt building)
├── codeact/js JS/TS runner (Ramune — JSC or QuickJS)
├── codeact/python Python runner (pyffi/CPython)
├── codeact/eino Eino adapter
├── codeact/adk Google ADK adapter
├── codeact/langchain LangChainGo adapter
└── codeact/blades Blades adapter
Runtime and adapter modules are independent — each has its own go.mod and only pulls in the dependencies it needs. aifunc is part of the core module with zero external dependencies.
Security
The JS runner supports fine-grained permission control via WithPermissions. File writes and process execution are denied by default.
The Python runner has no permission sandbox. Neither runtime provides OS-level isolation. For untrusted code, combine with containers, nsjail, or bubblewrap.
License
MIT