From 0a758945566a8281bdd024820bcb0c90fa46b577 Mon Sep 17 00:00:00 2001
From: Donnie Adams <donnie@acorn.io>
Date: Thu, 23 May 2024 20:45:22 -0400
Subject: [PATCH] feat: add a new SDK server

This server will be used in the SDKs for running gptscripts. This change
also includes an implementation that allows the SDKs to "confirm" tools
execution.

Signed-off-by: Donnie Adams <donnie@acorn.io>
---
 go.mod                      |   3 +-
 go.sum                      |   6 +-
 pkg/auth/auth.go            |  51 ++++----
 pkg/builtin/builtin.go      | 242 +++++++++++++++++++++---------------
 pkg/cli/eval.go             |  20 +--
 pkg/cli/gptscript.go        |  13 +-
 pkg/cli/sdk_server.go       |  28 +++++
 pkg/context/context.go      |  29 +++++
 pkg/engine/daemon.go        |  85 +++++++------
 pkg/engine/engine.go        |   1 -
 pkg/engine/http.go          |   2 +-
 pkg/gptscript/gptscript.go  |  15 +--
 pkg/loader/loader.go        |  16 ++-
 pkg/loader/openapi.go       |  24 ++--
 pkg/monitor/display.go      |   4 +-
 pkg/monitor/fd.go           |   4 +-
 pkg/mvl/log.go              |  18 ++-
 pkg/parser/parser_test.go   | 152 ++++++++++++++--------
 pkg/runner/runner.go        |  25 ++--
 pkg/sdkserver/confirm.go    |  87 +++++++++++++
 pkg/sdkserver/middleware.go |  65 ++++++++++
 pkg/sdkserver/routes.go     | 237 +++++++++++++++++++++++++++++++++++
 pkg/sdkserver/run.go        | 171 +++++++++++++++++++++++++
 pkg/sdkserver/server.go     |  95 ++++++++++++++
 pkg/sdkserver/types.go      | 194 +++++++++++++++++++++++++++++
 pkg/server/server.go        |  14 +--
 pkg/types/tool.go           |  20 +--
 27 files changed, 1323 insertions(+), 298 deletions(-)
 create mode 100644 pkg/cli/sdk_server.go
 create mode 100644 pkg/sdkserver/confirm.go
 create mode 100644 pkg/sdkserver/middleware.go
 create mode 100644 pkg/sdkserver/routes.go
 create mode 100644 pkg/sdkserver/run.go
 create mode 100644 pkg/sdkserver/server.go
 create mode 100644 pkg/sdkserver/types.go

diff --git a/go.mod b/go.mod
index b5585508..49eeb5da 100644
--- a/go.mod
+++ b/go.mod
@@ -14,13 +14,14 @@ require (
 	github.com/fatih/color v1.16.0
 	github.com/getkin/kin-openapi v0.123.0
 	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
+	github.com/google/uuid v1.6.0
 	github.com/gptscript-ai/chat-completion-client v0.0.0-20240515050533-bdef9f2226a9
 	github.com/hexops/autogold/v2 v2.2.1
 	github.com/hexops/valast v1.4.4
 	github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056
 	github.com/mholt/archiver/v4 v4.0.0-alpha.8
 	github.com/olahol/melody v1.1.4
-	github.com/rs/cors v1.10.1
+	github.com/rs/cors v1.11.0
 	github.com/samber/lo v1.38.1
 	github.com/sirupsen/logrus v1.9.3
 	github.com/spf13/cobra v1.8.0
diff --git a/go.sum b/go.sum
index a4cbe1b6..563ffedb 100644
--- a/go.sum
+++ b/go.sum
@@ -119,6 +119,8 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
@@ -219,8 +221,8 @@ github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/f
 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
 github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
 github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
-github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
-github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
+github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
+github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
 github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go
index 8f8d4544..5c818902 100644
--- a/pkg/auth/auth.go
+++ b/pkg/auth/auth.go
@@ -15,24 +15,43 @@ import (
 func Authorize(ctx engine.Context, input string) (runner.AuthorizerResponse, error) {
 	defer context.GetPauseFuncFromCtx(ctx.Ctx)()()
 
-	if !ctx.Tool.IsCommand() {
+	if IsSafe(ctx) {
 		return runner.AuthorizerResponse{
 			Accept: true,
 		}, nil
 	}
 
+	var result bool
+	err := survey.AskOne(&survey.Confirm{
+		Help:    fmt.Sprintf("The full source of the tools is as follows:\n\n%s", ctx.Tool.String()),
+		Default: true,
+		Message: ConfirmMessage(ctx, input),
+	}, &result)
+	if err != nil {
+		return runner.AuthorizerResponse{}, err
+	}
+
+	return runner.AuthorizerResponse{
+		Accept:  result,
+		Message: "Request denied, blocking execution.",
+	}, nil
+}
+
+func IsSafe(ctx engine.Context) bool {
+	if !ctx.Tool.IsCommand() {
+		return true
+	}
+
+	_, ok := builtin.SafeTools[strings.Split(ctx.Tool.Instructions, "\n")[0][2:]]
+	return ok
+}
+
+func ConfirmMessage(ctx engine.Context, input string) string {
 	var (
-		result      bool
 		loc         = ctx.Tool.Source.Location
 		interpreter = strings.Split(ctx.Tool.Instructions, "\n")[0][2:]
 	)
 
-	if _, ok := builtin.SafeTools[interpreter]; ok {
-		return runner.AuthorizerResponse{
-			Accept: true,
-		}, nil
-	}
-
 	if ctx.Tool.Source.Repo != nil {
 		loc = ctx.Tool.Source.Repo.Root
 		loc = strings.TrimPrefix(loc, "https://")
@@ -44,21 +63,9 @@ func Authorize(ctx engine.Context, input string) (runner.AuthorizerResponse, err
 		loc = "Builtin"
 	}
 
-	err := survey.AskOne(&survey.Confirm{
-		Help:    fmt.Sprintf("The full source of the tools is as follows:\n\n%s", ctx.Tool.String()),
-		Default: true,
-		Message: fmt.Sprintf(`Description: %s
+	return fmt.Sprintf(`Description: %s
   Interpreter: %s
   Source: %s
   Input: %s
-Allow the above tool to execute?`, ctx.Tool.Description, interpreter, loc, strings.TrimSpace(input)),
-	}, &result)
-	if err != nil {
-		return runner.AuthorizerResponse{}, err
-	}
-
-	return runner.AuthorizerResponse{
-		Accept:  result,
-		Message: "Request denied, blocking execution.",
-	}, nil
+Allow the above tool to execute?`, ctx.Tool.Description, interpreter, loc, strings.TrimSpace(input))
 }
diff --git a/pkg/builtin/builtin.go b/pkg/builtin/builtin.go
index 7ee3c00c..63e6e215 100644
--- a/pkg/builtin/builtin.go
+++ b/pkg/builtin/builtin.go
@@ -35,161 +35,197 @@ var SafeTools = map[string]struct{}{
 
 var tools = map[string]types.Tool{
 	"sys.time.now": {
-		Parameters: types.Parameters{
-			Description: "Returns the current date and time in RFC3339 format",
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Returns the current date and time in RFC3339 format",
+			},
+			BuiltinFunc: SysTimeNow,
 		},
-		BuiltinFunc: SysTimeNow,
 	},
 	"sys.ls": {
-		Parameters: types.Parameters{
-			Description: "Lists the contents of a directory",
-			Arguments: types.ObjectSchema(
-				"dir", "The directory to list"),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Lists the contents of a directory",
+				Arguments: types.ObjectSchema(
+					"dir", "The directory to list"),
+			},
+			BuiltinFunc: SysLs,
 		},
-		BuiltinFunc: SysLs,
 	},
 	"sys.read": {
-		Parameters: types.Parameters{
-			Description: "Reads the contents of a file",
-			Arguments: types.ObjectSchema(
-				"filename", "The name of the file to read"),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Reads the contents of a file",
+				Arguments: types.ObjectSchema(
+					"filename", "The name of the file to read"),
+			},
+			BuiltinFunc: SysRead,
 		},
-		BuiltinFunc: SysRead,
 	},
 	"sys.write": {
-		Parameters: types.Parameters{
-			Description: "Write the contents to a file",
-			Arguments: types.ObjectSchema(
-				"filename", "The name of the file to write to",
-				"content", "The content to write"),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Write the contents to a file",
+				Arguments: types.ObjectSchema(
+					"filename", "The name of the file to write to",
+					"content", "The content to write"),
+			},
+			BuiltinFunc: SysWrite,
 		},
-		BuiltinFunc: SysWrite,
 	},
 	"sys.append": {
-		Parameters: types.Parameters{
-			Description: "Appends the contents to a file",
-			Arguments: types.ObjectSchema(
-				"filename", "The name of the file to append to",
-				"content", "The content to append"),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Appends the contents to a file",
+				Arguments: types.ObjectSchema(
+					"filename", "The name of the file to append to",
+					"content", "The content to append"),
+			},
+			BuiltinFunc: SysAppend,
 		},
-		BuiltinFunc: SysAppend,
 	},
 	"sys.http.get": {
-		Parameters: types.Parameters{
-			Description: "Download the contents of a http or https URL",
-			Arguments: types.ObjectSchema(
-				"url", "The URL to download"),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Download the contents of a http or https URL",
+				Arguments: types.ObjectSchema(
+					"url", "The URL to download"),
+			},
+			BuiltinFunc: SysHTTPGet,
 		},
-		BuiltinFunc: SysHTTPGet,
 	},
 	"sys.http.html2text": {
-		Parameters: types.Parameters{
-			Description: "Download the contents of a http or https URL returning the content as rendered text converted from HTML",
-			Arguments: types.ObjectSchema(
-				"url", "The URL to download"),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Download the contents of a http or https URL returning the content as rendered text converted from HTML",
+				Arguments: types.ObjectSchema(
+					"url", "The URL to download"),
+			},
+			BuiltinFunc: SysHTTPHtml2Text,
 		},
-		BuiltinFunc: SysHTTPHtml2Text,
 	},
 	"sys.abort": {
-		Parameters: types.Parameters{
-			Description: "Aborts execution",
-			Arguments: types.ObjectSchema(
-				"message", "The description of the error or unexpected result that caused abort to be called",
-			),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Aborts execution",
+				Arguments: types.ObjectSchema(
+					"message", "The description of the error or unexpected result that caused abort to be called",
+				),
+			},
+			BuiltinFunc: SysAbort,
 		},
-		BuiltinFunc: SysAbort,
 	},
 	"sys.chat.finish": {
-		Parameters: types.Parameters{
-			Description: "Concludes the conversation. This can not be used to ask a question.",
-			Arguments: types.ObjectSchema(
-				"return", "The instructed value to return or a summary of the dialog if no value is instructed",
-			),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Concludes the conversation. This can not be used to ask a question.",
+				Arguments: types.ObjectSchema(
+					"return", "The instructed value to return or a summary of the dialog if no value is instructed",
+				),
+			},
+			BuiltinFunc: SysChatFinish,
 		},
-		BuiltinFunc: SysChatFinish,
 	},
 	"sys.http.post": {
-		Parameters: types.Parameters{
-			Description: "Write contents to a http or https URL using the POST method",
-			Arguments: types.ObjectSchema(
-				"url", "The URL to POST to",
-				"content", "The content to POST",
-				"contentType", "The \"content type\" of the content such as application/json or text/plain"),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Write contents to a http or https URL using the POST method",
+				Arguments: types.ObjectSchema(
+					"url", "The URL to POST to",
+					"content", "The content to POST",
+					"contentType", "The \"content type\" of the content such as application/json or text/plain"),
+			},
+			BuiltinFunc: SysHTTPPost,
 		},
-		BuiltinFunc: SysHTTPPost,
 	},
 	"sys.find": {
-		Parameters: types.Parameters{
-			Description: "Traverse a directory looking for files that match a pattern in the style of the unix find command",
-			Arguments: types.ObjectSchema(
-				"pattern", "The file pattern to look for. The pattern is a traditional unix glob format with * matching any character and ? matching a single character",
-				"directory", "The directory to search in. The current directory \".\" will be used as the default if no argument is passed",
-			),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Traverse a directory looking for files that match a pattern in the style of the unix find command",
+				Arguments: types.ObjectSchema(
+					"pattern", "The file pattern to look for. The pattern is a traditional unix glob format with * matching any character and ? matching a single character",
+					"directory", "The directory to search in. The current directory \".\" will be used as the default if no argument is passed",
+				),
+			},
+			BuiltinFunc: SysFind,
 		},
-		BuiltinFunc: SysFind,
 	},
 	"sys.exec": {
-		Parameters: types.Parameters{
-			Description: "Execute a command and get the output of the command",
-			Arguments: types.ObjectSchema(
-				"command", "The command to run including all applicable arguments",
-				"directory", "The directory to use as the current working directory of the command. The current directory \".\" will be used if no argument is passed",
-			),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Execute a command and get the output of the command",
+				Arguments: types.ObjectSchema(
+					"command", "The command to run including all applicable arguments",
+					"directory", "The directory to use as the current working directory of the command. The current directory \".\" will be used if no argument is passed",
+				),
+			},
+			BuiltinFunc: SysExec,
 		},
-		BuiltinFunc: SysExec,
 	},
 	"sys.getenv": {
-		Parameters: types.Parameters{
-			Description: "Gets the value of an OS environment variable",
-			Arguments: types.ObjectSchema(
-				"name", "The environment variable name to lookup"),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Gets the value of an OS environment variable",
+				Arguments: types.ObjectSchema(
+					"name", "The environment variable name to lookup"),
+			},
+			BuiltinFunc: SysGetenv,
 		},
-		BuiltinFunc: SysGetenv,
 	},
 	"sys.download": {
-		Parameters: types.Parameters{
-			Description: "Downloads a URL, saving the contents to disk at a given location",
-			Arguments: types.ObjectSchema(
-				"url", "The URL to download, either http or https.",
-				"location", "(optional) The on disk location to store the file. If no location is specified a temp location will be used. If the target file already exists it will fail unless override is set to true.",
-				"override", "If true and a file at the location exists, the file will be overwritten, otherwise fail. Default is false"),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Downloads a URL, saving the contents to disk at a given location",
+				Arguments: types.ObjectSchema(
+					"url", "The URL to download, either http or https.",
+					"location", "(optional) The on disk location to store the file. If no location is specified a temp location will be used. If the target file already exists it will fail unless override is set to true.",
+					"override", "If true and a file at the location exists, the file will be overwritten, otherwise fail. Default is false"),
+			},
+			BuiltinFunc: SysDownload,
 		},
-		BuiltinFunc: SysDownload,
 	},
 	"sys.remove": {
-		Parameters: types.Parameters{
-			Description: "Removes the specified files",
-			Arguments: types.ObjectSchema(
-				"location", "The file to remove"),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Removes the specified files",
+				Arguments: types.ObjectSchema(
+					"location", "The file to remove"),
+			},
+			BuiltinFunc: SysRemove,
 		},
-		BuiltinFunc: SysRemove,
 	},
 	"sys.stat": {
-		Parameters: types.Parameters{
-			Description: "Gets size, modfied time, and mode of the specified file",
-			Arguments: types.ObjectSchema(
-				"filepath", "The complete path and filename of the file",
-			),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Gets size, modfied time, and mode of the specified file",
+				Arguments: types.ObjectSchema(
+					"filepath", "The complete path and filename of the file",
+				),
+			},
+			BuiltinFunc: SysStat,
 		},
-		BuiltinFunc: SysStat,
 	},
 	"sys.prompt": {
-		Parameters: types.Parameters{
-			Description: "Prompts the user for input",
-			Arguments: types.ObjectSchema(
-				"message", "The message to display to the user",
-				"fields", "A comma-separated list of fields to prompt for",
-				"sensitive", "(true or false) Whether the input should be hidden",
-			),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Prompts the user for input",
+				Arguments: types.ObjectSchema(
+					"message", "The message to display to the user",
+					"fields", "A comma-separated list of fields to prompt for",
+					"sensitive", "(true or false) Whether the input should be hidden",
+				),
+			},
+			BuiltinFunc: SysPrompt,
 		},
-		BuiltinFunc: SysPrompt,
 	},
 	"sys.chat.history": {
-		Parameters: types.Parameters{
-			Description: "Retrieves the previous chat dialog",
-			Arguments:   types.ObjectSchema(),
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: "Retrieves the previous chat dialog",
+				Arguments:   types.ObjectSchema(),
+			},
+			BuiltinFunc: SysChatHistory,
 		},
-		BuiltinFunc: SysChatHistory,
 	},
 }
 
diff --git a/pkg/cli/eval.go b/pkg/cli/eval.go
index addab89d..cc13290e 100644
--- a/pkg/cli/eval.go
+++ b/pkg/cli/eval.go
@@ -28,16 +28,18 @@ type Eval struct {
 
 func (e *Eval) Run(cmd *cobra.Command, args []string) error {
 	tool := types.Tool{
-		Parameters: types.Parameters{
-			Description:    "inline script",
-			Tools:          e.Tools,
-			MaxTokens:      e.MaxTokens,
-			ModelName:      e.Model,
-			JSONResponse:   e.JSON,
-			InternalPrompt: e.InternalPrompt,
-			Chat:           e.Chat,
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description:    "inline script",
+				Tools:          e.Tools,
+				MaxTokens:      e.MaxTokens,
+				ModelName:      e.Model,
+				JSONResponse:   e.JSON,
+				InternalPrompt: e.InternalPrompt,
+				Chat:           e.Chat,
+			},
+			Instructions: strings.Join(args, " "),
 		},
-		Instructions: strings.Join(args, " "),
 	}
 
 	if e.Temperature != "" {
diff --git a/pkg/cli/gptscript.go b/pkg/cli/gptscript.go
index 38502ceb..25575527 100644
--- a/pkg/cli/gptscript.go
+++ b/pkg/cli/gptscript.go
@@ -73,12 +73,13 @@ func New() *cobra.Command {
 	root := &GPTScript{}
 	command := cmd.Command(
 		root,
-		&Eval{
-			gptscript: root,
-		},
+		&Eval{gptscript: root},
 		&Credential{root: root},
 		&Parse{},
 		&Fmt{},
+		&SDKServer{
+			GPTScript: root,
+		},
 	)
 
 	// Hide all the global flags for the credential subcommand.
@@ -290,7 +291,7 @@ func (r *GPTScript) readProgram(ctx context.Context, runner *gptscript.GPTScript
 }
 
 func (r *GPTScript) PrintOutput(toolInput, toolOutput string) (err error) {
-	if r.Output != "" {
+	if r.Output != "" && r.Output != "-" {
 		err = os.WriteFile(r.Output, []byte(toolOutput), 0644)
 		if err != nil {
 			return err
@@ -328,7 +329,7 @@ func (r *GPTScript) Run(cmd *cobra.Command, args []string) (retErr error) {
 		if err != nil {
 			return err
 		}
-		defer s.Close()
+		defer s.Close(true)
 		return s.Start(ctx)
 	}
 
@@ -336,7 +337,7 @@ func (r *GPTScript) Run(cmd *cobra.Command, args []string) (retErr error) {
 	if err != nil {
 		return err
 	}
-	defer gptScript.Close()
+	defer gptScript.Close(true)
 
 	if r.ListModels {
 		return r.listModels(ctx, gptScript, args)
diff --git a/pkg/cli/sdk_server.go b/pkg/cli/sdk_server.go
new file mode 100644
index 00000000..29700549
--- /dev/null
+++ b/pkg/cli/sdk_server.go
@@ -0,0 +1,28 @@
+package cli
+
+import (
+	"github.com/gptscript-ai/gptscript/pkg/sdkserver"
+	"github.com/spf13/cobra"
+)
+
+type SDKServer struct {
+	*GPTScript
+}
+
+func (c *SDKServer) Customize(cmd *cobra.Command) {
+	cmd.Args = cobra.NoArgs
+	cmd.Hidden = true
+}
+
+func (c *SDKServer) Run(cmd *cobra.Command, _ []string) error {
+	opts, err := c.NewGPTScriptOpts()
+	if err != nil {
+		return err
+	}
+
+	return sdkserver.Start(cmd.Context(), sdkserver.Options{
+		Options:       opts,
+		ListenAddress: c.ListenAddress,
+		Debug:         c.Debug,
+	})
+}
diff --git a/pkg/context/context.go b/pkg/context/context.go
index 3ec88c21..31474f6c 100644
--- a/pkg/context/context.go
+++ b/pkg/context/context.go
@@ -2,6 +2,9 @@ package context
 
 import (
 	"context"
+
+	"github.com/google/uuid"
+	"github.com/gptscript-ai/gptscript/pkg/mvl"
 )
 
 type pauseKey struct{}
@@ -17,3 +20,29 @@ func GetPauseFuncFromCtx(ctx context.Context) func() func() {
 	}
 	return pauseF
 }
+
+type reqIDKey struct{}
+
+func WithNewRequestID(ctx context.Context) context.Context {
+	return context.WithValue(ctx, reqIDKey{}, uuid.NewString())
+}
+
+func GetRequestID(ctx context.Context) string {
+	s, _ := ctx.Value(reqIDKey{}).(string)
+	return s
+}
+
+type loggerKey struct{}
+
+func WithLogger(ctx context.Context, log mvl.Logger) context.Context {
+	return context.WithValue(ctx, loggerKey{}, log)
+}
+
+func GetLogger(ctx context.Context) mvl.Logger {
+	l, ok := ctx.Value(loggerKey{}).(mvl.Logger)
+	if !ok {
+		return mvl.New("")
+	}
+
+	return l
+}
diff --git a/pkg/engine/daemon.go b/pkg/engine/daemon.go
index 00c8e79c..a797cc76 100644
--- a/pkg/engine/daemon.go
+++ b/pkg/engine/daemon.go
@@ -14,6 +14,8 @@ import (
 	"github.com/gptscript-ai/gptscript/pkg/types"
 )
 
+var ports Ports
+
 type Ports struct {
 	daemonPorts map[string]int64
 	daemonLock  sync.Mutex
@@ -25,32 +27,36 @@ type Ports struct {
 	daemonWG           sync.WaitGroup
 }
 
-func (p *Ports) SetPorts(start, end int64) {
-	p.startPort = start
-	p.endPort = end
+func SetPorts(start, end int64) {
+	ports.daemonLock.Lock()
+	defer ports.daemonLock.Unlock()
+	if ports.startPort == 0 && ports.endPort == 0 {
+		ports.startPort = start
+		ports.endPort = end
+	}
 }
 
-func (p *Ports) CloseDaemons() {
-	p.daemonLock.Lock()
-	if p.daemonCtx == nil {
-		p.daemonLock.Unlock()
+func CloseDaemons() {
+	ports.daemonLock.Lock()
+	if ports.daemonCtx == nil {
+		ports.daemonLock.Unlock()
 		return
 	}
-	p.daemonLock.Unlock()
+	ports.daemonLock.Unlock()
 
-	p.daemonClose()
-	p.daemonWG.Wait()
+	ports.daemonClose()
+	ports.daemonWG.Wait()
 }
 
-func (p *Ports) NextPort() int64 {
-	if p.startPort == 0 {
-		p.startPort = 10240
-		p.endPort = 11240
+func nextPort() int64 {
+	if ports.startPort == 0 {
+		ports.startPort = 10240
+		ports.endPort = 11240
 	}
 	// This is pretty simple and inefficient approach, but also never releases ports
-	count := p.endPort - p.startPort + 1
+	count := ports.endPort - ports.startPort + 1
 	toTry := make([]int64, 0, count)
-	for i := p.startPort; i <= p.endPort; i++ {
+	for i := ports.startPort; i <= ports.endPort; i++ {
 		toTry = append(toTry, i)
 	}
 
@@ -59,13 +65,13 @@ func (p *Ports) NextPort() int64 {
 	})
 
 	for _, nextPort := range toTry {
-		if _, ok := p.usedPorts[nextPort]; ok {
+		if _, ok := ports.usedPorts[nextPort]; ok {
 			continue
 		}
-		if p.usedPorts == nil {
-			p.usedPorts = map[int64]struct{}{}
+		if ports.usedPorts == nil {
+			ports.usedPorts = map[int64]struct{}{}
 		}
-		p.usedPorts[nextPort] = struct{}{}
+		ports.usedPorts[nextPort] = struct{}{}
 		return nextPort
 	}
 
@@ -93,26 +99,31 @@ func getPath(instructions string) (string, string) {
 	return strings.TrimSpace(rest), strings.TrimSpace(value)
 }
 
-func (e *Engine) startDaemon(_ context.Context, tool types.Tool) (string, error) {
-	e.Ports.daemonLock.Lock()
-	defer e.Ports.daemonLock.Unlock()
+func (e *Engine) startDaemon(tool types.Tool) (string, error) {
+	ports.daemonLock.Lock()
+	defer ports.daemonLock.Unlock()
 
 	instructions := strings.TrimPrefix(tool.Instructions, types.DaemonPrefix)
 	instructions, path := getPath(instructions)
 	tool.Instructions = types.CommandPrefix + instructions
 
-	port, ok := e.Ports.daemonPorts[tool.ID]
+	port, ok := ports.daemonPorts[tool.ID]
 	url := fmt.Sprintf("http://127.0.0.1:%d%s", port, path)
 	if ok {
 		return url, nil
 	}
 
-	if e.Ports.daemonCtx == nil {
-		e.Ports.daemonCtx, e.Ports.daemonClose = context.WithCancel(context.Background())
+	if ports.daemonCtx == nil {
+		var cancel func()
+		ports.daemonCtx, cancel = context.WithCancel(context.Background())
+		ports.daemonClose = func() {
+			cancel()
+			ports.daemonCtx = nil
+		}
 	}
 
-	ctx := e.Ports.daemonCtx
-	port = e.Ports.NextPort()
+	ctx := ports.daemonCtx
+	port = nextPort()
 	url = fmt.Sprintf("http://127.0.0.1:%d%s", port, path)
 
 	cmd, stop, err := e.newCommand(ctx, []string{
@@ -149,15 +160,15 @@ func (e *Engine) startDaemon(_ context.Context, tool types.Tool) (string, error)
 		return url, err
 	}
 
-	if e.Ports.daemonPorts == nil {
-		e.Ports.daemonPorts = map[string]int64{}
+	if ports.daemonPorts == nil {
+		ports.daemonPorts = map[string]int64{}
 	}
-	e.Ports.daemonPorts[tool.ID] = port
+	ports.daemonPorts[tool.ID] = port
 
 	killedCtx, cancel := context.WithCancelCause(ctx)
 	defer cancel(nil)
 
-	e.Ports.daemonWG.Add(1)
+	ports.daemonWG.Add(1)
 	go func() {
 		err := cmd.Wait()
 		if err != nil {
@@ -168,11 +179,11 @@ func (e *Engine) startDaemon(_ context.Context, tool types.Tool) (string, error)
 
 		cancel(err)
 		stop()
-		e.Ports.daemonLock.Lock()
-		defer e.Ports.daemonLock.Unlock()
+		ports.daemonLock.Lock()
+		defer ports.daemonLock.Unlock()
 
-		delete(e.Ports.daemonPorts, tool.ID)
-		e.Ports.daemonWG.Done()
+		delete(ports.daemonPorts, tool.ID)
+		ports.daemonWG.Done()
 	}()
 
 	for i := 0; i < 120; i++ {
@@ -195,7 +206,7 @@ func (e *Engine) startDaemon(_ context.Context, tool types.Tool) (string, error)
 }
 
 func (e *Engine) runDaemon(ctx context.Context, prg *types.Program, tool types.Tool, input string) (cmdRet *Return, cmdErr error) {
-	url, err := e.startDaemon(ctx, tool)
+	url, err := e.startDaemon(tool)
 	if err != nil {
 		return nil, err
 	}
diff --git a/pkg/engine/engine.go b/pkg/engine/engine.go
index a6eb9bdb..501cbe65 100644
--- a/pkg/engine/engine.go
+++ b/pkg/engine/engine.go
@@ -26,7 +26,6 @@ type Engine struct {
 	RuntimeManager RuntimeManager
 	Env            []string
 	Progress       chan<- types.CompletionStatus
-	Ports          *Ports
 }
 
 type State struct {
diff --git a/pkg/engine/http.go b/pkg/engine/http.go
index 94f741dc..a81f1bb3 100644
--- a/pkg/engine/http.go
+++ b/pkg/engine/http.go
@@ -43,7 +43,7 @@ func (e *Engine) runHTTP(ctx context.Context, prg *types.Program, tool types.Too
 		if !ok {
 			return nil, fmt.Errorf("failed to find tool [%s] for [%s]", referencedToolName, parsed.Hostname())
 		}
-		toolURL, err = e.startDaemon(ctx, referencedTool)
+		toolURL, err = e.startDaemon(referencedTool)
 		if err != nil {
 			return nil, err
 		}
diff --git a/pkg/gptscript/gptscript.go b/pkg/gptscript/gptscript.go
index ad48eed5..d079e845 100644
--- a/pkg/gptscript/gptscript.go
+++ b/pkg/gptscript/gptscript.go
@@ -65,10 +65,10 @@ func New(opts *Options) (*GPTScript, error) {
 		return nil, err
 	}
 
-	oAIClient, err := openai.NewClient(append([]openai.Options{opts.OpenAI}, openai.Options{
+	oAIClient, err := openai.NewClient(opts.OpenAI, openai.Options{
 		Cache:   cacheClient,
 		SetSeed: true,
-	})...)
+	})
 	if err != nil {
 		return nil, err
 	}
@@ -78,9 +78,7 @@ func New(opts *Options) (*GPTScript, error) {
 	}
 
 	if opts.Runner.MonitorFactory == nil {
-		opts.Runner.MonitorFactory = monitor.NewConsole(append([]monitor.Options{opts.Monitor}, monitor.Options{
-			DisplayProgress: !*opts.Quiet,
-		})...)
+		opts.Runner.MonitorFactory = monitor.NewConsole(opts.Monitor, monitor.Options{DebugMessages: *opts.Quiet})
 	}
 
 	if opts.Runner.RuntimeManager == nil {
@@ -148,13 +146,16 @@ func (g *GPTScript) Run(ctx context.Context, prg types.Program, envs []string, i
 	return g.Runner.Run(ctx, prg, envs, input)
 }
 
-func (g *GPTScript) Close() {
-	g.Runner.Close()
+func (g *GPTScript) Close(closeDaemons bool) {
 	if g.DeleteWorkspaceOnClose && g.WorkspacePath != "" {
 		if err := os.RemoveAll(g.WorkspacePath); err != nil {
 			log.Errorf("failed to delete workspace %s: %s", g.WorkspacePath, err)
 		}
 	}
+
+	if closeDaemons {
+		engine.CloseDaemons()
+	}
 }
 
 func (g *GPTScript) GetModel() engine.Model {
diff --git a/pkg/loader/loader.go b/pkg/loader/loader.go
index ad80555d..36c920c4 100644
--- a/pkg/loader/loader.go
+++ b/pkg/loader/loader.go
@@ -101,9 +101,7 @@ func loadLocal(base *source, name string) (*source, bool, error) {
 }
 
 func loadProgram(data []byte, into *types.Program, targetToolName string) (types.Tool, error) {
-	var (
-		ext types.Program
-	)
+	var ext types.Program
 
 	if err := json.Unmarshal(data[len(assemble.Header):], &ext); err != nil {
 		return types.Tool{}, err
@@ -192,9 +190,7 @@ func readTool(ctx context.Context, cache *cache.Client, prg *types.Program, base
 		return []types.Tool{tool}, nil
 	}
 
-	var (
-		tools []types.Tool
-	)
+	var tools []types.Tool
 
 	if openAPIDocument := loadOpenAPI(prg, data); openAPIDocument != nil {
 		var err error
@@ -211,10 +207,12 @@ func readTool(ctx context.Context, cache *cache.Client, prg *types.Program, base
 	if ext := path.Ext(base.Name); len(tools) == 0 && ext != "" && ext != system.Suffix && utf8.Valid(data) {
 		tools = []types.Tool{
 			{
-				Parameters: types.Parameters{
-					Name: base.Name,
+				ToolDef: types.ToolDef{
+					Parameters: types.Parameters{
+						Name: base.Name,
+					},
+					Instructions: types.EchoPrefix + "\n" + string(data),
 				},
-				Instructions: types.EchoPrefix + "\n" + string(data),
 			},
 		}
 	}
diff --git a/pkg/loader/openapi.go b/pkg/loader/openapi.go
index 658a26ab..bedb92a8 100644
--- a/pkg/loader/openapi.go
+++ b/pkg/loader/openapi.go
@@ -131,13 +131,15 @@ func getOpenAPITools(t *openapi3.T, defaultHost string) ([]types.Tool, error) {
 				bodyMIME         string
 			)
 			tool := types.Tool{
-				Parameters: types.Parameters{
-					Name:        operation.OperationID,
-					Description: toolDesc,
-					Arguments: &openapi3.Schema{
-						Type:       "object",
-						Properties: openapi3.Schemas{},
-						Required:   []string{},
+				ToolDef: types.ToolDef{
+					Parameters: types.Parameters{
+						Name:        operation.OperationID,
+						Description: toolDesc,
+						Arguments: &openapi3.Schema{
+							Type:       "object",
+							Properties: openapi3.Schemas{},
+							Required:   []string{},
+						},
 					},
 				},
 				Source: types.ToolSource{
@@ -279,9 +281,11 @@ func getOpenAPITools(t *openapi3.T, defaultHost string) ([]types.Tool, error) {
 
 	// The first tool we generate is a special tool that just exports all the others.
 	exportTool := types.Tool{
-		Parameters: types.Parameters{
-			Description: fmt.Sprintf("This is a tool set for the %s OpenAPI spec", t.Info.Title),
-			Export:      toolNames,
+		ToolDef: types.ToolDef{
+			Parameters: types.Parameters{
+				Description: fmt.Sprintf("This is a tool set for the %s OpenAPI spec", t.Info.Title),
+				Export:      toolNames,
+			},
 		},
 		Source: types.ToolSource{
 			LineNo: 0,
diff --git a/pkg/monitor/display.go b/pkg/monitor/display.go
index 9cdae6c2..2ddb236e 100644
--- a/pkg/monitor/display.go
+++ b/pkg/monitor/display.go
@@ -51,7 +51,7 @@ func (c *Console) Start(_ context.Context, prg *types.Program, _ []string, input
 	mon.dump.Program = prg
 	mon.dump.Input = input
 
-	log.Fields("runID", mon.dump.ID, "input", input, "program", prg, "type", "runStart").Debugf("Run started")
+	log.Fields("runID", mon.dump.ID, "input", input, "program", prg, "type", runner.EventTypeRunStart).Debugf("Run started")
 	return mon, nil
 }
 
@@ -288,7 +288,7 @@ func (d *display) Stop(output string, err error) {
 	d.callLock.Lock()
 	defer d.callLock.Unlock()
 
-	log.Fields("runID", d.dump.ID, "output", output, "err", err, "type", "runFinish").Debugf("Run stopped")
+	log.Fields("runID", d.dump.ID, "output", output, "err", err, "type", runner.EventTypeRunFinish).Debugf("Run stopped")
 	if d.usage.TotalTokens > 0 {
 		log.Fields("runID", d.dump.ID, "total", d.usage.TotalTokens, "prompt", d.usage.PromptTokens, "completion", d.usage.CompletionTokens).Infof("usage   ")
 	}
diff --git a/pkg/monitor/fd.go b/pkg/monitor/fd.go
index 6ec4d38c..f5fea312 100644
--- a/pkg/monitor/fd.go
+++ b/pkg/monitor/fd.go
@@ -66,7 +66,7 @@ func (s fileFactory) Start(_ context.Context, prg *types.Program, env []string,
 	fd.event(Event{
 		Event: runner.Event{
 			Time: time.Now(),
-			Type: "runStart",
+			Type: runner.EventTypeRunStart,
 		},
 		Program: prg,
 	})
@@ -107,7 +107,7 @@ func (f *fd) Stop(output string, err error) {
 	e := Event{
 		Event: runner.Event{
 			Time: time.Now(),
-			Type: "runFinish",
+			Type: runner.EventTypeRunFinish,
 		},
 		Input:  f.input,
 		Output: output,
diff --git a/pkg/mvl/log.go b/pkg/mvl/log.go
index 1523982f..a7cacf9b 100644
--- a/pkg/mvl/log.go
+++ b/pkg/mvl/log.go
@@ -91,6 +91,19 @@ func Package() Logger {
 	return New(p)
 }
 
+func NewWithFields(fields logrus.Fields) Logger {
+	return Logger{
+		log:    logrus.StandardLogger(),
+		fields: fields,
+	}
+}
+
+func NewWithID(id string) Logger {
+	return NewWithFields(logrus.Fields{
+		"id": id,
+	})
+}
+
 func New(name string) Logger {
 	var fields logrus.Fields
 	if name != "" {
@@ -98,10 +111,7 @@ func New(name string) Logger {
 			"logger": name,
 		}
 	}
-	return Logger{
-		log:    logrus.StandardLogger(),
-		fields: fields,
-	}
+	return NewWithFields(fields)
 }
 
 func SetOutput(out io.Writer) {
diff --git a/pkg/parser/parser_test.go b/pkg/parser/parser_test.go
index 88fee31a..9cdb593e 100644
--- a/pkg/parser/parser_test.go
+++ b/pkg/parser/parser_test.go
@@ -24,7 +24,7 @@ func TestHashBang(t *testing.T) {
 }
 
 func TestParseGlobals(t *testing.T) {
-	var input = `
+	input := `
 global tools: foo, bar
 global model: the model
 ---
@@ -36,39 +36,49 @@ tools: bar
 	})
 	require.NoError(t, err)
 	autogold.Expect(Document{Nodes: []Node{
-		{ToolNode: &ToolNode{
-			Tool: types.Tool{
-				Parameters: types.Parameters{
-					ModelName: "the model",
-					Tools: []string{
-						"foo",
-						"bar",
+		{
+			ToolNode: &ToolNode{
+				Tool: types.Tool{
+					ToolDef: types.ToolDef{
+						Parameters: types.Parameters{
+							ModelName: "the model",
+							Tools: []string{
+								"foo",
+								"bar",
+							},
+							GlobalTools: []string{
+								"foo",
+								"bar",
+							},
+							GlobalModelName: "the model",
+						},
 					},
-					GlobalTools: []string{
-						"foo",
-						"bar",
-					},
-					GlobalModelName: "the model",
+					Source: types.ToolSource{LineNo: 1},
 				},
-				Source: types.ToolSource{LineNo: 1},
 			},
-		}},
-		{ToolNode: &ToolNode{Tool: types.Tool{
-			Parameters: types.Parameters{
-				Name:      "bar",
-				ModelName: "the model",
-				Tools: []string{
-					"bar",
-					"foo",
+		},
+		{
+			ToolNode: &ToolNode{
+				Tool: types.Tool{
+					ToolDef: types.ToolDef{
+						Parameters: types.Parameters{
+							Name:      "bar",
+							ModelName: "the model",
+							Tools: []string{
+								"bar",
+								"foo",
+							},
+						},
+					},
+					Source: types.ToolSource{LineNo: 5},
 				},
 			},
-			Source: types.ToolSource{LineNo: 5},
-		}}},
+		},
 	}}).Equal(t, out)
 }
 
 func TestParseSkip(t *testing.T) {
-	var input = `
+	input := `
 first
 ---
 name: second
@@ -102,30 +112,58 @@ name: seven
 	out, err := Parse(strings.NewReader(input))
 	require.NoError(t, err)
 	autogold.Expect(Document{Nodes: []Node{
-		{ToolNode: &ToolNode{
-			Tool: types.Tool{
-				Instructions: "first",
-				Source: types.ToolSource{
-					LineNo: 1,
+		{
+			ToolNode: &ToolNode{
+				Tool: types.Tool{
+					ToolDef: types.ToolDef{
+						Instructions: "first",
+					},
+					Source: types.ToolSource{
+						LineNo: 1,
+					},
 				},
 			},
-		}},
-		{ToolNode: &ToolNode{Tool: types.Tool{
-			Parameters: types.Parameters{Name: "second"},
-			Source:     types.ToolSource{LineNo: 4},
-		}}},
-		{TextNode: &TextNode{Text: "!third\n\nname: third\n"}},
-		{ToolNode: &ToolNode{Tool: types.Tool{
-			Parameters:   types.Parameters{Name: "fourth"},
-			Instructions: "!forth dont skip",
-			Source:       types.ToolSource{LineNo: 11},
-		}}},
-		{ToolNode: &ToolNode{Tool: types.Tool{
-			Parameters:   types.Parameters{Name: "fifth"},
-			Instructions: "#!ignore",
-			Source:       types.ToolSource{LineNo: 14},
-		}}},
-		{TextNode: &TextNode{Text: `!skip
+		},
+		{
+			ToolNode: &ToolNode{
+				Tool: types.Tool{
+					ToolDef: types.ToolDef{
+						Parameters: types.Parameters{Name: "second"},
+					},
+					Source: types.ToolSource{LineNo: 4},
+				},
+			},
+		},
+		{
+			TextNode: &TextNode{
+				Text: "!third\n\nname: third\n",
+			},
+		},
+		{
+			ToolNode: &ToolNode{
+				Tool: types.Tool{
+					ToolDef: types.ToolDef{
+						Parameters:   types.Parameters{Name: "fourth"},
+						Instructions: "!forth dont skip",
+					},
+					Source: types.ToolSource{LineNo: 11},
+				},
+			},
+		},
+		{
+			ToolNode: &ToolNode{
+				Tool: types.Tool{
+					ToolDef: types.ToolDef{
+						Parameters:   types.Parameters{Name: "fifth"},
+						Instructions: "#!ignore",
+					},
+					Source: types.ToolSource{LineNo: 14},
+				},
+			},
+		},
+		{
+			TextNode: &TextNode{
+				Text: `!skip
 name: six
 
 ----
@@ -136,12 +174,20 @@ name: bad
 name: bad
 ---
 name: bad
-`}},
-		{ToolNode: &ToolNode{Tool: types.Tool{
-			Parameters: types.Parameters{
-				Name: "seven",
+`,
+			},
+		},
+		{
+			ToolNode: &ToolNode{
+				Tool: types.Tool{
+					ToolDef: types.ToolDef{
+						Parameters: types.Parameters{
+							Name: "seven",
+						},
+					},
+					Source: types.ToolSource{LineNo: 30},
+				},
 			},
-			Source: types.ToolSource{LineNo: 30},
-		}}},
+		},
 	}}).Equal(t, out)
 }
diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go
index 00ba524d..a4877472 100644
--- a/pkg/runner/runner.go
+++ b/pkg/runner/runner.go
@@ -46,7 +46,7 @@ type AuthorizerResponse struct {
 
 type AuthorizerFunc func(ctx engine.Context, input string) (AuthorizerResponse, error)
 
-func DefaultAuthorizer(_ engine.Context, _ string) (AuthorizerResponse, error) {
+func DefaultAuthorizer(engine.Context, string) (AuthorizerResponse, error) {
 	return AuthorizerResponse{
 		Accept: true,
 	}, nil
@@ -84,7 +84,6 @@ type Runner struct {
 	auth           AuthorizerFunc
 	factory        MonitorFactory
 	runtimeManager engine.RuntimeManager
-	ports          engine.Ports
 	credCtx        string
 	credMutex      sync.Mutex
 	credOverrides  string
@@ -109,16 +108,12 @@ func New(client engine.Model, credCtx string, opts ...Options) (*Runner, error)
 		if opt.EndPort < opt.StartPort {
 			return nil, fmt.Errorf("invalid port range: %d-%d", opt.StartPort, opt.EndPort)
 		}
-		runner.ports.SetPorts(opt.StartPort, opt.EndPort)
+		engine.SetPorts(opt.StartPort, opt.EndPort)
 	}
 
 	return runner, nil
 }
 
-func (r *Runner) Close() {
-	r.ports.CloseDaemons()
-}
-
 type ChatResponse struct {
 	Done    bool      `json:"done"`
 	Content string    `json:"content"`
@@ -226,12 +221,14 @@ type Event struct {
 type EventType string
 
 var (
-	EventTypeCallStart    = EventType("callStart")
-	EventTypeCallContinue = EventType("callContinue")
-	EventTypeCallSubCalls = EventType("callSubCalls")
-	EventTypeCallProgress = EventType("callProgress")
-	EventTypeChat         = EventType("callChat")
-	EventTypeCallFinish   = EventType("callFinish")
+	EventTypeRunStart     EventType = "runStart"
+	EventTypeCallStart    EventType = "callStart"
+	EventTypeCallContinue EventType = "callContinue"
+	EventTypeCallSubCalls EventType = "callSubCalls"
+	EventTypeCallProgress EventType = "callProgress"
+	EventTypeChat         EventType = "callChat"
+	EventTypeCallFinish   EventType = "callFinish"
+	EventTypeRunFinish    EventType = "runFinish"
 )
 
 func getContextInput(prg *types.Program, ref types.ToolReference, input string) (string, error) {
@@ -422,7 +419,6 @@ func (r *Runner) start(callCtx engine.Context, state *State, monitor Monitor, en
 		RuntimeManager: r.runtimeManager,
 		Progress:       progress,
 		Env:            env,
-		Ports:          &r.ports,
 	}
 
 	callCtx.Ctx = context2.AddPauseFuncToCtx(callCtx.Ctx, monitor.Pause)
@@ -601,7 +597,6 @@ func (r *Runner) resume(callCtx engine.Context, monitor Monitor, env []string, s
 			RuntimeManager: r.runtimeManager,
 			Progress:       progress,
 			Env:            env,
-			Ports:          &r.ports,
 		}
 
 		var (
diff --git a/pkg/sdkserver/confirm.go b/pkg/sdkserver/confirm.go
new file mode 100644
index 00000000..2ed34bb3
--- /dev/null
+++ b/pkg/sdkserver/confirm.go
@@ -0,0 +1,87 @@
+package sdkserver
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/gptscript-ai/gptscript/pkg/auth"
+	gcontext "github.com/gptscript-ai/gptscript/pkg/context"
+	"github.com/gptscript-ai/gptscript/pkg/engine"
+	"github.com/gptscript-ai/gptscript/pkg/runner"
+	gserver "github.com/gptscript-ai/gptscript/pkg/server"
+)
+
+func (s *server) authorize(ctx engine.Context, input string) (runner.AuthorizerResponse, error) {
+	if auth.IsSafe(ctx) {
+		return runner.AuthorizerResponse{
+			Accept: true,
+		}, nil
+	}
+
+	s.lock.RLock()
+	authChan := s.waitingToConfirm[ctx.ID]
+	s.lock.RUnlock()
+
+	if authChan != nil {
+		return runner.AuthorizerResponse{}, fmt.Errorf("authorize called multiple times for same ID: %s", ctx.ID)
+	}
+
+	runID := gserver.RunIDFromContext(ctx.Ctx)
+	s.lock.Lock()
+	authChan = make(chan runner.AuthorizerResponse)
+	s.waitingToConfirm[ctx.ID] = authChan
+	s.lock.Unlock()
+	defer func(id string) {
+		s.lock.Lock()
+		delete(s.waitingToConfirm, id)
+		s.lock.Unlock()
+	}(ctx.ID)
+
+	s.events.C <- gserver.Event{
+		Event: runner.Event{
+			Time:        time.Now(),
+			CallContext: ctx.GetCallContext(),
+			Type:        CallConfirm,
+		},
+		Input: input,
+		RunID: runID,
+	}
+
+	// Wait for the confirmation to come through.
+	select {
+	case <-ctx.Ctx.Done():
+		return runner.AuthorizerResponse{}, ctx.Ctx.Err()
+	case authResponse := <-authChan:
+		return authResponse, nil
+	}
+}
+
+func (s *server) confirm(w http.ResponseWriter, r *http.Request) {
+	logger := gcontext.GetLogger(r.Context())
+	id := r.PathValue("id")
+
+	s.lock.RLock()
+	authChan := s.waitingToConfirm[id]
+	s.lock.RUnlock()
+
+	if authChan == nil {
+		writeError(logger, w, http.StatusNotFound, fmt.Errorf("no confirmation found with id %q", id))
+		return
+	}
+
+	var authResponse runner.AuthorizerResponse
+	if err := json.NewDecoder(r.Body).Decode(&authResponse); err != nil {
+		writeError(logger, w, http.StatusBadRequest, fmt.Errorf("failed to decode request body: %w", err))
+		return
+	}
+
+	// Don't block here because, if the authorizer is no longer waiting on this then it will never unblock.
+	select {
+	case authChan <- authResponse:
+		w.WriteHeader(http.StatusAccepted)
+	default:
+		w.WriteHeader(http.StatusConflict)
+	}
+}
diff --git a/pkg/sdkserver/middleware.go b/pkg/sdkserver/middleware.go
new file mode 100644
index 00000000..887ff639
--- /dev/null
+++ b/pkg/sdkserver/middleware.go
@@ -0,0 +1,65 @@
+package sdkserver
+
+import (
+	"net/http"
+	"runtime/debug"
+
+	"github.com/gptscript-ai/gptscript/pkg/context"
+	"github.com/gptscript-ai/gptscript/pkg/mvl"
+)
+
+type middleware func(http.Handler) http.Handler
+
+func apply(h http.Handler, m ...func(http.Handler) http.Handler) http.Handler {
+	for i := len(m) - 1; i >= 0; i-- {
+		h = m[i](h)
+	}
+	return h
+}
+
+func contentType(contentTypes ...string) middleware {
+	return func(h http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			for _, ct := range contentTypes {
+				w.Header().Add("Content-Type", ct)
+			}
+			h.ServeHTTP(w, r)
+		})
+	}
+}
+
+func logRequest(h http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		l := context.GetLogger(r.Context())
+
+		defer func() {
+			if err := recover(); err != nil {
+				l.Fields("stack", string(debug.Stack())).Errorf("Panic: %v", err)
+				w.WriteHeader(http.StatusInternalServerError)
+				_, _ = w.Write([]byte(`{"stderr": "encountered an unexpected error"}`))
+			}
+		}()
+
+		l.Infof("Handling request: method %s, path %s", r.Method, r.URL.Path)
+		h.ServeHTTP(w, r)
+		l.Infof("Handled request: method %s, path %s", r.Method, r.URL.Path)
+	})
+}
+
+func addRequestID(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		next.ServeHTTP(w, r.WithContext(context.WithNewRequestID(r.Context())))
+	})
+}
+
+func addLogger(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		next.ServeHTTP(
+			w,
+			r.WithContext(context.WithLogger(
+				r.Context(),
+				mvl.NewWithID(context.GetRequestID(r.Context())),
+			)),
+		)
+	})
+}
diff --git a/pkg/sdkserver/routes.go b/pkg/sdkserver/routes.go
new file mode 100644
index 00000000..913e5a77
--- /dev/null
+++ b/pkg/sdkserver/routes.go
@@ -0,0 +1,237 @@
+package sdkserver
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+
+	"github.com/acorn-io/broadcaster"
+	gcontext "github.com/gptscript-ai/gptscript/pkg/context"
+	"github.com/gptscript-ai/gptscript/pkg/gptscript"
+	"github.com/gptscript-ai/gptscript/pkg/loader"
+	"github.com/gptscript-ai/gptscript/pkg/parser"
+	"github.com/gptscript-ai/gptscript/pkg/runner"
+	gserver "github.com/gptscript-ai/gptscript/pkg/server"
+	"github.com/gptscript-ai/gptscript/pkg/types"
+	"github.com/gptscript-ai/gptscript/pkg/version"
+)
+
+const toolRunTimeout = 15 * time.Minute
+
+type server struct {
+	client *gptscript.GPTScript
+	events *broadcaster.Broadcaster[gserver.Event]
+
+	lock             sync.RWMutex
+	waitingToConfirm map[string]chan runner.AuthorizerResponse
+}
+
+func (s *server) addRoutes(mux *http.ServeMux) {
+	mux.HandleFunc("GET /healthz", s.health)
+
+	mux.HandleFunc("GET /version", s.version)
+
+	// Listing tools supports listing system tools (GET) or listing tools in a gptscript (POST).
+	mux.HandleFunc("POST /list-tools", s.listTools)
+	mux.HandleFunc("GET /list-tools", s.listTools)
+	// Listing models supports listing OpenAI models (GET) or listing models from providers (POST).
+	mux.HandleFunc("POST /list-models", s.listModels)
+	mux.HandleFunc("GET /list-models", s.listModels)
+
+	mux.HandleFunc("POST /run", s.execHandler)
+	mux.HandleFunc("POST /evaluate", s.execHandler)
+
+	mux.HandleFunc("POST /parse", s.parse)
+	mux.HandleFunc("POST /fmt", s.fmtDocument)
+
+	mux.HandleFunc("POST /confirm/{id}", s.confirm)
+}
+
+// health just provides an endpoint for checking whether the server is running and accessible.
+func (s *server) health(w http.ResponseWriter, r *http.Request) {
+	writeResponse(gcontext.GetLogger(r.Context()), w, map[string]string{"stdout": "ok"})
+}
+
+// version will return the output of `gptscript --version`
+func (s *server) version(w http.ResponseWriter, r *http.Request) {
+	writeResponse(gcontext.GetLogger(r.Context()), w, map[string]any{"stdout": fmt.Sprintf("%s version %s", version.ProgramName, version.Get().String())})
+}
+
+// listTools will return the output of `gptscript --list-tools`
+func (s *server) listTools(w http.ResponseWriter, r *http.Request) {
+	logger := gcontext.GetLogger(r.Context())
+	var prg types.Program
+	if r.ContentLength != 0 {
+		reqObject := new(toolOrFileRequest)
+		err := json.NewDecoder(r.Body).Decode(reqObject)
+		if err != nil {
+			writeError(logger, w, http.StatusBadRequest, fmt.Errorf("failed to decode request body: %w", err))
+			return
+		}
+
+		if reqObject.Content != "" {
+			prg, err = loader.ProgramFromSource(r.Context(), reqObject.Content, reqObject.SubTool, loader.Options{Cache: s.client.Cache})
+		} else if reqObject.File != "" {
+			prg, err = loader.Program(r.Context(), reqObject.File, reqObject.SubTool, loader.Options{Cache: s.client.Cache})
+		} else {
+			prg, err = loader.ProgramFromSource(r.Context(), reqObject.ToolDef.String(), reqObject.SubTool, loader.Options{Cache: s.client.Cache})
+		}
+		if err != nil {
+			writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to load program: %w", err))
+			return
+		}
+	}
+
+	tools := s.client.ListTools(r.Context(), prg)
+	sort.Slice(tools, func(i, j int) bool {
+		return tools[i].Name < tools[j].Name
+	})
+
+	lines := make([]string, 0, len(tools))
+	for _, tool := range tools {
+		if tool.Name == "" {
+			tool.Name = prg.Name
+		}
+
+		// Don't print instructions
+		tool.Instructions = ""
+
+		lines = append(lines, tool.String())
+	}
+
+	writeResponse(logger, w, map[string]any{"stdout": strings.Join(lines, "\n---\n")})
+}
+
+// listModels will return the output of `gptscript --list-models`
+func (s *server) listModels(w http.ResponseWriter, r *http.Request) {
+	logger := gcontext.GetLogger(r.Context())
+	var providers []string
+	if r.ContentLength != 0 {
+		reqObject := new(modelsRequest)
+		if err := json.NewDecoder(r.Body).Decode(reqObject); err != nil {
+			writeError(logger, w, http.StatusBadRequest, fmt.Errorf("failed to decode request body: %w", err))
+			return
+		}
+
+		providers = reqObject.Providers
+	}
+
+	out, err := s.client.ListModels(r.Context(), providers...)
+	if err != nil {
+		writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to list models: %w", err))
+		return
+	}
+
+	writeResponse(logger, w, map[string]any{"stdout": strings.Join(out, "\n")})
+}
+
+// execHandler is a general handler for executing tools with gptscript. This is mainly responsible for parsing the request body.
+// Then the options and tool are passed to the process function.
+func (s *server) execHandler(w http.ResponseWriter, r *http.Request) {
+	logger := gcontext.GetLogger(r.Context())
+	body, err := io.ReadAll(r.Body)
+	if err != nil {
+		writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to read request body: %w", err))
+		return
+	}
+
+	reqObject := new(toolOrFileRequest)
+	if err := json.Unmarshal(body, reqObject); err != nil {
+		writeError(logger, w, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err))
+		return
+	}
+
+	ctx, cancel := context.WithTimeout(gserver.ContextWithNewRunID(r.Context()), toolRunTimeout)
+	defer cancel()
+
+	// Ensure chat state is not empty.
+	if reqObject.ChatState == "" {
+		reqObject.ChatState = "null"
+	}
+
+	logger.Debugf("executing tool: %+v", reqObject)
+	var (
+		def           fmt.Stringer = &reqObject.ToolDef
+		programLoader loaderFunc   = loader.ProgramFromSource
+	)
+	if reqObject.Content != "" {
+		def = &reqObject.content
+	} else if reqObject.File != "" {
+		def = &reqObject.file
+		programLoader = loader.Program
+	}
+
+	opts := &gptscript.Options{
+		Cache:             reqObject.Options,
+		Env:               reqObject.Env,
+		Workspace:         reqObject.Workspace,
+		CredentialContext: reqObject.CredentialContext,
+		Runner: runner.Options{
+			// Set the monitor factory so that we can get events from the server.
+			MonitorFactory: gserver.NewSessionFactory(s.events),
+		},
+	}
+
+	if reqObject.Confirm {
+		opts.Runner.Authorizer = s.authorize
+	}
+
+	s.execAndStream(ctx, programLoader, logger, w, opts, reqObject.ChatState, reqObject.Input, reqObject.SubTool, def)
+}
+
+// parse will parse the file and return the corresponding Document.
+func (s *server) parse(w http.ResponseWriter, r *http.Request) {
+	logger := gcontext.GetLogger(r.Context())
+	reqObject := new(parseRequest)
+	if err := json.NewDecoder(r.Body).Decode(reqObject); err != nil {
+		writeError(logger, w, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err))
+		return
+	}
+
+	logger.Debugf("parsing file: file=%s, content=%s", reqObject.File, reqObject.Content)
+
+	var (
+		out parser.Document
+		err error
+	)
+
+	if reqObject.Content != "" {
+		out, err = parser.Parse(strings.NewReader(reqObject.Content), reqObject.Options)
+	} else {
+		var file *os.File
+		file, err = os.Open(reqObject.File)
+		if err != nil {
+			logger.Errorf("failed to open file: %v", err)
+			writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to open file: %w", err))
+			return
+		}
+		out, err = parser.Parse(file, reqObject.Options)
+		_ = file.Close()
+	}
+	if err != nil {
+		logger.Errorf("failed to parse file: %v", err)
+		writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to parse file: %w", err))
+		return
+	}
+
+	writeResponse(logger, w, map[string]any{"stdout": map[string]any{"nodes": out.Nodes}})
+}
+
+// fmtDocument will produce a string representation of the document.
+func (s *server) fmtDocument(w http.ResponseWriter, r *http.Request) {
+	logger := gcontext.GetLogger(r.Context())
+	doc := new(parser.Document)
+	if err := json.NewDecoder(r.Body).Decode(doc); err != nil {
+		writeError(logger, w, http.StatusBadRequest, fmt.Errorf("invalid request body: %w", err))
+		return
+	}
+
+	writeResponse(logger, w, map[string]string{"stdout": doc.String()})
+}
diff --git a/pkg/sdkserver/run.go b/pkg/sdkserver/run.go
new file mode 100644
index 00000000..97194f87
--- /dev/null
+++ b/pkg/sdkserver/run.go
@@ -0,0 +1,171 @@
+package sdkserver
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+
+	"github.com/gptscript-ai/gptscript/pkg/gptscript"
+	"github.com/gptscript-ai/gptscript/pkg/loader"
+	"github.com/gptscript-ai/gptscript/pkg/mvl"
+	"github.com/gptscript-ai/gptscript/pkg/runner"
+	gserver "github.com/gptscript-ai/gptscript/pkg/server"
+	"github.com/gptscript-ai/gptscript/pkg/types"
+)
+
+type loaderFunc func(context.Context, string, string, ...loader.Options) (types.Program, error)
+
+func (s *server) execAndStream(ctx context.Context, programLoader loaderFunc, logger mvl.Logger, w http.ResponseWriter, opts *gptscript.Options, chatState, input, subTool string, toolDef fmt.Stringer) {
+	g, err := gptscript.New(opts)
+	if err != nil {
+		writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to initialize gptscript: %w", err))
+		return
+	}
+	defer g.Close(false)
+
+	prg, err := programLoader(ctx, toolDef.String(), subTool, loader.Options{Cache: g.Cache})
+	if err != nil {
+		writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to load program: %w", err))
+		return
+	}
+
+	errChan := make(chan error)
+	programOutput := make(chan runner.ChatResponse)
+	events := s.events.Subscribe()
+	defer events.Close()
+
+	go func() {
+		run, err := g.Chat(ctx, chatState, prg, opts.Env, input)
+		if err != nil {
+			errChan <- err
+		} else {
+			programOutput <- run
+		}
+		close(errChan)
+		close(programOutput)
+	}()
+
+	processEventStreamOutput(ctx, logger, w, gserver.RunIDFromContext(ctx), events.C, programOutput, errChan)
+}
+
+// processEventStreamOutput will stream the events of the tool to the response as server sent events.
+// If an error occurs, then an event with the error will also be sent.
+func processEventStreamOutput(ctx context.Context, logger mvl.Logger, w http.ResponseWriter, id string, events <-chan gserver.Event, output <-chan runner.ChatResponse, errChan chan error) {
+	run := newRun(id)
+	setStreamingHeaders(w)
+
+	streamEvents(ctx, logger, w, run, events)
+
+	var out runner.ChatResponse
+	select {
+	case <-ctx.Done():
+	case out = <-output:
+		run.processStdout(out)
+
+		writeServerSentEvent(logger, w, map[string]any{
+			"stdout": out,
+		})
+	case err := <-errChan:
+		writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to run file: %w", err))
+	}
+
+	// Now that we have received all events, send the DONE event.
+	_, err := w.Write([]byte("data: [DONE]\n\n"))
+	if err == nil {
+		if f, ok := w.(http.Flusher); ok {
+			f.Flush()
+		}
+	}
+
+	logger.Debugf("wrote DONE event")
+}
+
+// streamEvents will stream the events of the tool to the response as server sent events.
+func streamEvents(ctx context.Context, logger mvl.Logger, w http.ResponseWriter, run *runInfo, events <-chan gserver.Event) {
+	logger.Debugf("receiving events")
+	for {
+		select {
+		case <-ctx.Done():
+			logger.Debugf("context canceled while receiving events")
+			go func() {
+				//nolint:revive
+				for range events {
+				}
+			}()
+			return
+		case e, ok := <-events:
+			if ok && e.RunID != run.ID {
+				continue
+			}
+
+			if !ok {
+				logger.Debugf("done receiving events")
+				return
+			}
+
+			writeServerSentEvent(logger, w, run.process(e))
+
+			if e.Type == runner.EventTypeRunFinish {
+				logger.Debugf("finished receiving events")
+				return
+			}
+		}
+	}
+}
+
+func writeResponse(logger mvl.Logger, w http.ResponseWriter, v any) {
+	b, err := json.Marshal(v)
+	if err != nil {
+		writeError(logger, w, http.StatusInternalServerError, fmt.Errorf("failed to marshal response: %w", err))
+		return
+	}
+
+	_, _ = w.Write(b)
+	if f, ok := w.(http.Flusher); ok {
+		f.Flush()
+	}
+}
+
+func writeError(logger mvl.Logger, w http.ResponseWriter, code int, err error) {
+	logger.Debugf("Writing error response with code %d: %v", code, err)
+
+	w.WriteHeader(code)
+	resp := map[string]any{
+		"stderr": err.Error(),
+	}
+
+	b, err := json.Marshal(resp)
+	if err != nil {
+		_, _ = w.Write([]byte(fmt.Sprintf(`{"stderr": "%s"}`, err.Error())))
+		return
+	}
+
+	_, _ = w.Write(b)
+	if f, ok := w.(http.Flusher); ok {
+		f.Flush()
+	}
+}
+
+func writeServerSentEvent(logger mvl.Logger, w http.ResponseWriter, event any) {
+	ev, err := json.Marshal(event)
+	if err != nil {
+		logger.Warnf("failed to marshal event: %v", err)
+		return
+	}
+
+	_, err = w.Write([]byte(fmt.Sprintf("data: %s\n\n", ev)))
+	if err == nil {
+		if f, ok := w.(http.Flusher); ok {
+			f.Flush()
+		}
+	}
+
+	logger.Debugf("wrote event: %v", string(ev))
+}
+
+func setStreamingHeaders(w http.ResponseWriter) {
+	w.Header().Set("Content-Type", "text/event-stream")
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Connection", "keep-alive")
+}
diff --git a/pkg/sdkserver/server.go b/pkg/sdkserver/server.go
new file mode 100644
index 00000000..2a134af7
--- /dev/null
+++ b/pkg/sdkserver/server.go
@@ -0,0 +1,95 @@
+package sdkserver
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"net/http"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"github.com/acorn-io/broadcaster"
+	"github.com/gptscript-ai/gptscript/pkg/gptscript"
+	"github.com/gptscript-ai/gptscript/pkg/mvl"
+	"github.com/gptscript-ai/gptscript/pkg/runner"
+	gserver "github.com/gptscript-ai/gptscript/pkg/server"
+	"github.com/rs/cors"
+)
+
+type Options struct {
+	gptscript.Options
+
+	ListenAddress string
+	Debug         bool
+}
+
+func Start(ctx context.Context, opts Options) error {
+	sigCtx, cancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT, syscall.SIGKILL)
+	defer cancel()
+	go func() {
+		// This is a hack. This server will be run as a forked process in the SDKs. The SDKs will hold stdin open for as long
+		// as it wants the server running. When stdin is closed (or the parent process dies), then this will unblock and the
+		// server will be shutdown.
+		_, _ = io.ReadAll(os.Stdin)
+		cancel()
+	}()
+
+	if opts.Debug {
+		mvl.SetDebug()
+	}
+
+	events := broadcaster.New[gserver.Event]()
+	opts.Options.Runner.MonitorFactory = gserver.NewSessionFactory(events)
+	go events.Start(ctx)
+
+	g, err := gptscript.New(&opts.Options)
+	if err != nil {
+		return err
+	}
+
+	s := &server{
+		client:           g,
+		events:           events,
+		waitingToConfirm: make(map[string]chan runner.AuthorizerResponse),
+	}
+	defer s.Close()
+
+	s.addRoutes(http.DefaultServeMux)
+
+	server := http.Server{
+		Addr: opts.ListenAddress,
+		Handler: apply(http.DefaultServeMux,
+			contentType("application/json"),
+			addRequestID,
+			addLogger,
+			logRequest,
+			cors.Default().Handler,
+		),
+	}
+
+	slog.Info("Starting server", "addr", server.Addr)
+
+	context.AfterFunc(sigCtx, func() {
+		ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
+		defer cancel()
+
+		slog.Info("Shutting down server")
+		_ = server.Shutdown(ctx)
+		slog.Info("Server stopped")
+	})
+
+	if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+		return fmt.Errorf("server error: %w", err)
+	}
+
+	return nil
+}
+
+func (s *server) Close() {
+	s.client.Close(true)
+	s.events.Close()
+}
diff --git a/pkg/sdkserver/types.go b/pkg/sdkserver/types.go
new file mode 100644
index 00000000..9f4f5e31
--- /dev/null
+++ b/pkg/sdkserver/types.go
@@ -0,0 +1,194 @@
+package sdkserver
+
+import (
+	"maps"
+	"time"
+
+	"github.com/gptscript-ai/gptscript/pkg/cache"
+	"github.com/gptscript-ai/gptscript/pkg/engine"
+	"github.com/gptscript-ai/gptscript/pkg/parser"
+	"github.com/gptscript-ai/gptscript/pkg/runner"
+	gserver "github.com/gptscript-ai/gptscript/pkg/server"
+	"github.com/gptscript-ai/gptscript/pkg/types"
+)
+
+type runState string
+
+const (
+	Creating runState = "creating"
+	Running  runState = "running"
+	Continue runState = "continue"
+	Finished runState = "finished"
+	Error    runState = "error"
+
+	CallConfirm runner.EventType = "callConfirm"
+)
+
+type toolOrFileRequest struct {
+	cache.Options `json:",inline"`
+	types.ToolDef `json:",inline"`
+	content       `json:",inline"`
+	file          `json:",inline"`
+
+	SubTool           string   `json:"subTool"`
+	Input             string   `json:"input"`
+	ChatState         string   `json:"chatState"`
+	Workspace         string   `json:"workspace"`
+	Env               []string `json:"env"`
+	CredentialContext string   `json:"credentialContext"`
+	Confirm           bool     `json:"confirm"`
+}
+
+type content struct {
+	Content string `json:"content"`
+}
+
+func (c *content) String() string {
+	return c.Content
+}
+
+type file struct {
+	File string `json:"file"`
+}
+
+func (f *file) String() string {
+	return f.File
+}
+
+type parseRequest struct {
+	parser.Options `json:",inline"`
+	content        `json:",inline"`
+
+	File string `json:"file"`
+}
+
+type modelsRequest struct {
+	Providers []string `json:"providers"`
+}
+
+type runInfo struct {
+	Calls     map[string]call `json:"-"`
+	ID        string          `json:"id"`
+	Program   types.Program   `json:"program"`
+	Input     string          `json:"input"`
+	Output    string          `json:"output"`
+	Error     string          `json:"error"`
+	Start     time.Time       `json:"start"`
+	End       time.Time       `json:"end"`
+	State     runState        `json:"state"`
+	ChatState any             `json:"chatState"`
+}
+
+func newRun(id string) *runInfo {
+	return &runInfo{
+		ID:    id,
+		State: Creating,
+		Calls: make(map[string]call),
+	}
+}
+
+type runEvent struct {
+	runInfo `json:",inline"`
+
+	Type runner.EventType `json:"type"`
+}
+
+func (r *runInfo) process(event gserver.Event) map[string]any {
+	switch event.Type {
+	case runner.EventTypeRunStart:
+		r.Start = event.Time
+		r.Program = *event.Program
+		r.State = Running
+	case runner.EventTypeRunFinish:
+		r.End = event.Time
+		r.Output = event.Output
+		r.Error = event.Err
+		if r.Error != "" {
+			r.State = Error
+		} else {
+			r.State = Finished
+		}
+	}
+
+	if event.CallContext == nil || event.CallContext.ID == "" {
+		return map[string]any{"run": runEvent{
+			runInfo: *r,
+			Type:    event.Type,
+		}}
+	}
+
+	call := r.Calls[event.CallContext.ID]
+	call.CallContext = *event.CallContext
+	call.Type = event.Type
+
+	switch event.Type {
+	case runner.EventTypeCallStart:
+		call.Start = event.Time
+		call.Input = event.Content
+
+	case runner.EventTypeCallSubCalls:
+		call.setSubCalls(event.ToolSubCalls)
+
+	case runner.EventTypeCallProgress:
+		call.setOutput(event.Content)
+
+	case runner.EventTypeCallFinish:
+		call.End = event.Time
+		call.setOutput(event.Content)
+
+	case runner.EventTypeChat:
+		if event.ChatRequest != nil {
+			call.LLMRequest = event.ChatRequest
+		}
+		if event.ChatResponse != nil {
+			call.LLMResponse = event.ChatResponse
+		}
+	}
+
+	r.Calls[event.CallContext.ID] = call
+	return map[string]any{"call": call}
+}
+
+func (r *runInfo) processStdout(cs runner.ChatResponse) {
+	if cs.Done {
+		r.State = Finished
+	} else {
+		r.State = Continue
+	}
+
+	r.ChatState = cs.State
+}
+
+type call struct {
+	engine.CallContext `json:",inline"`
+
+	Type        runner.EventType `json:"type"`
+	Start       time.Time        `json:"start"`
+	End         time.Time        `json:"end"`
+	Input       string           `json:"input"`
+	Output      []output         `json:"output"`
+	Usage       types.Usage      `json:"usage"`
+	LLMRequest  any              `json:"llmRequest"`
+	LLMResponse any              `json:"llmResponse"`
+}
+
+func (c *call) setSubCalls(subCalls map[string]engine.Call) {
+	c.Output = append(c.Output, output{
+		SubCalls: maps.Clone(subCalls),
+	})
+}
+
+func (c *call) setOutput(o string) {
+	if len(c.Output) == 0 || len(c.Output[len(c.Output)-1].SubCalls) > 0 {
+		c.Output = append(c.Output, output{
+			Content: o,
+		})
+	} else {
+		c.Output[len(c.Output)-1].Content = o
+	}
+}
+
+type output struct {
+	Content  string                 `json:"content"`
+	SubCalls map[string]engine.Call `json:"subCalls"`
+}
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 3b96d670..d1605886 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -89,16 +89,16 @@ var (
 
 type execKey struct{}
 
-func ContextWithNewID(ctx context.Context) context.Context {
+func ContextWithNewRunID(ctx context.Context) context.Context {
 	return context.WithValue(ctx, execKey{}, counter.Next())
 }
 
-func IDFromContext(ctx context.Context) string {
+func RunIDFromContext(ctx context.Context) string {
 	return ctx.Value(execKey{}).(string)
 }
 
-func (s *Server) Close() {
-	s.runner.Close()
+func (s *Server) Close(closeDaemons bool) {
+	s.runner.Close(closeDaemons)
 }
 
 func (s *Server) list(rw http.ResponseWriter, req *http.Request) {
@@ -304,12 +304,12 @@ func NewSessionFactory(events *broadcaster.Broadcaster[Event]) *SessionFactory {
 }
 
 func (s SessionFactory) Start(ctx context.Context, prg *types.Program, env []string, input string) (runner.Monitor, error) {
-	id := IDFromContext(ctx)
+	id := RunIDFromContext(ctx)
 
 	s.events.C <- Event{
 		Event: runner.Event{
 			Time: time.Now(),
-			Type: "runStart",
+			Type: runner.EventTypeRunStart,
 		},
 		RunID:   id,
 		Program: prg,
@@ -347,7 +347,7 @@ func (s *Session) Stop(output string, err error) {
 	e := Event{
 		Event: runner.Event{
 			Time: time.Now(),
-			Type: "runFinish",
+			Type: runner.EventTypeRunFinish,
 		},
 		RunID:  s.id,
 		Input:  s.input,
diff --git a/pkg/types/tool.go b/pkg/types/tool.go
index 98bcce64..c4d20303 100644
--- a/pkg/types/tool.go
+++ b/pkg/types/tool.go
@@ -69,8 +69,10 @@ func (p Program) GetContextToolRefs(toolID string) ([]ToolReference, error) {
 
 func (p Program) GetCompletionTools() (result []CompletionTool, err error) {
 	return Tool{
-		Parameters: Parameters{
-			Tools: []string{"main"},
+		ToolDef: ToolDef{
+			Parameters: Parameters{
+				Tools: []string{"main"},
+			},
 		},
 		ToolMapping: map[string][]ToolReference{
 			"main": {
@@ -125,14 +127,18 @@ type Parameters struct {
 	Blocking        bool             `json:"-"`
 }
 
-type Tool struct {
+type ToolDef struct {
 	Parameters   `json:",inline"`
-	Instructions string `json:"instructions,omitempty"`
+	Instructions string      `json:"instructions,omitempty"`
+	BuiltinFunc  BuiltinFunc `json:"-"`
+}
+
+type Tool struct {
+	ToolDef `json:",inline"`
 
 	ID          string                     `json:"id,omitempty"`
 	ToolMapping map[string][]ToolReference `json:"toolMapping,omitempty"`
 	LocalTools  map[string]string          `json:"localTools,omitempty"`
-	BuiltinFunc BuiltinFunc                `json:"-"`
 	Source      ToolSource                 `json:"source,omitempty"`
 	WorkingDir  string                     `json:"workingDir,omitempty"`
 }
@@ -215,7 +221,7 @@ func (t Tool) GetToolRefsFromNames(names []string) (result []ToolReference, _ er
 	return
 }
 
-func (t Tool) String() string {
+func (t ToolDef) String() string {
 	buf := &strings.Builder{}
 	if t.Parameters.GlobalModelName != "" {
 		_, _ = fmt.Fprintf(buf, "Global Model Name: %s\n", t.Parameters.GlobalModelName)
@@ -276,7 +282,7 @@ func (t Tool) String() string {
 	if len(t.Parameters.Credentials) > 0 {
 		_, _ = fmt.Fprintf(buf, "Credentials: %s\n", strings.Join(t.Parameters.Credentials, ", "))
 	}
-	if t.Chat {
+	if t.Parameters.Chat {
 		_, _ = fmt.Fprintf(buf, "Chat: true\n")
 	}