Skip to content

Commit 23b0985

Browse files
committed
codex_sdk: add the typed plugin app-server API
Land typed plugin params, responses, and request helpers while keeping the raw wrappers stable.
1 parent eca302e commit 23b0985

20 files changed

Lines changed: 2418 additions & 74 deletions

README.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ An idiomatic Elixir SDK for embedding OpenAI's Codex agent in your workflows and
1919
- `guides/01-getting-started.md` - first threads, turns, and sessions
2020
- `guides/02-architecture.md` - transport layering and ownership boundaries
2121
- `guides/03-api-guide.md` - public modules and common call patterns
22+
- `guides/05-app-server-transport.md` - direct app-server requests and host controls
23+
- `guides/11-typed-plugin-api.md` - typed plugin params, responses, and migration notes
2224
- `guides/07-models-and-reasoning.md` - shared catalog projections and reasoning controls
2325
- `guides/08-configuration-defaults.md` - config precedence and default resolution
2426

@@ -340,7 +342,9 @@ App-server-only APIs include:
340342
- `Codex.AppServer.model_list/2`, `config_read/2`, `config_write/4`, `config_batch_write/3`, `config_requirements/1`
341343
- `Codex.AppServer.experimental_feature_list/2`, `experimental_feature_enablement_set/2`
342344
- `Codex.AppServer.fs_read_file/2`, `fs_write_file/3`, `fs_create_directory/3`, `fs_get_metadata/2`, `fs_read_directory/2`, `fs_remove/3`, `fs_copy/4`
343-
- `Codex.AppServer.plugin_list/2`, `plugin_read/3`, `plugin_install/4`, `plugin_uninstall/3`
345+
- raw plugin wrappers: `Codex.AppServer.plugin_list/2`, `plugin_read/3`, `plugin_install/4`, `plugin_uninstall/3`
346+
- typed plugin wrappers: `Codex.AppServer.plugin_list_typed/2`, `plugin_read_typed/3`, `plugin_install_typed/4`, `plugin_uninstall_typed/3`
347+
- `Codex.AppServer.request_typed/5` with `Codex.Protocol.Plugin.*` params/response modules
344348
- `Codex.AppServer.skills_config_write/3`, `collaboration_mode_list/1`, `apps_list/2`
345349
- `Codex.AppServer.turn_interrupt/3`
346350
- `Codex.AppServer.thread_shell_command/3` (thread-bound `!` workflow)
@@ -351,14 +355,16 @@ App-server-only APIs include:
351355

352356
On app-server transport, thread options now forward current upstream routing fields such as
353357
`ephemeral`, `service_name`, and `service_tier`; turn options can override `service_tier`
354-
per `Codex.Thread.run/3`. Plugin response maps also preserve newer upstream auth metadata such
355-
as `needsAuth`, and subscriptions adapt `mcpServer/startupStatus/updated` into typed
356-
`Codex.Events` structs.
358+
per `Codex.Thread.run/3`. Raw plugin response maps still preserve newer upstream auth metadata
359+
such as `needsAuth`, while the typed plugin API projects those payloads into
360+
`Codex.Protocol.Plugin.*` structs and preserves unknown upstream fields in `extra` maps.
361+
Subscriptions adapt `mcpServer/startupStatus/updated` into typed `Codex.Events` structs.
357362

358363
Runnable app-server demos now include `examples/live_app_server_filesystem.exs` for `fs/*`
359364
and `examples/live_app_server_plugins.exs` for `plugin/list` + `plugin/read` using a disposable
360365
repo-local marketplace fixture plus an isolated temporary `CODEX_HOME`, rather than your real
361-
plugin config; that example now also prints `needsAuth` when the connected build includes it.
366+
plugin config; that example now uses the typed plugin wrappers and prints derived
367+
`needs_auth` state from the typed app summaries while the raw wrappers remain available.
362368
`examples/live_app_server_approvals.exs` uses the same child-process isolation pattern to enable
363369
the under-development approval features only inside a temporary `CODEX_HOME`, so it can exercise
364370
live command/file/permissions approval flows without mutating your real Codex settings or writing

docs/python-parity-checklist.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This checklist tracks coverage of Python Codex SDK features in the Elixir port.
1212
| Error taxonomy coverage | `python/errors_transport.jsonl` | `Codex.ErrorTest` | ✅ Implemented | Typed transport errors mirror Python exit diagnostics. |
1313
| Telemetry lifecycle events | _N/A_ | `Codex.TelemetryTest` | ✅ Implemented | Thread start/stop/exception events and default logger attached via telemetry. |
1414
| Schema-backed typed protocol helpers | _N/A_ | `Codex.Protocol.RequestUserInputTest`, `Codex.Protocol.CollaborationModeTest`, `Codex.Protocol.RateLimitTest` | ✅ Implemented | Phase E moved these dynamic boundaries onto local `Zoi` schemas while keeping the public structs and preserving future-compatible keys for evolving wire surfaces. |
15+
| Typed app-server plugin API | app-server plugin protocol (`plugin/list`, `plugin/read`, `plugin/install`, `plugin/uninstall`) | `Codex.Protocol.PluginTest`, `Codex.AppServer.ApiTest` | ✅ Implemented | Phase F adds local `Codex.Protocol.Plugin.*` params/responses, `Codex.AppServer.request_typed/5`, typed wrapper functions beside the raw wrappers, `needsAuth` support on typed app summaries, and `extra` preservation for upstream fields beyond the current Python generation. |
1516

1617
Update this table as fixtures land and Elixir parity tests are implemented.
1718

examples/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ The `live_*.exs` scripts hit the live Codex CLI (no OPENAI_API_KEY needed if you
9797
- `examples/live_oauth_login.exs` — native OAuth status/login/refresh demo using an isolated temporary `CODEX_HOME` by default; prints the browser URL before waiting, supports `--browser`, `--device`, and `--no-browser`, and can optionally show memory-mode app-server auth via `--app-server-memory`
9898
- `examples/live_app_server_basic.exs` — minimal turn + skills/models/thread list over `codex app-server`
9999
- `examples/live_app_server_filesystem.exs` — end-to-end `fs/*` app-server demo (write/read/list/metadata/copy/remove); self-skips when the connected CLI build does not advertise those legacy parity methods
100-
- `examples/live_app_server_plugins.exs` — provisions a disposable local marketplace under the system temp directory, launches `codex app-server` with an isolated temporary `CODEX_HOME`, then exercises `plugin/list` + `plugin/read` without mutating your real `$CODEX_HOME` or requiring a preinstalled plugin; prints `needsAuth` when available and self-skips when the connected CLI build does not advertise `plugin/read`
100+
- `examples/live_app_server_plugins.exs` — provisions a disposable local marketplace under the system temp directory, launches `codex app-server` with an isolated temporary `CODEX_HOME`, then exercises the typed `plugin_list_typed/2` + `plugin_read_typed/3` wrappers without mutating your real `$CODEX_HOME` or requiring a preinstalled plugin; prints derived `needs_auth` state from typed app summaries and self-skips when the connected CLI build does not advertise `plugin/read`
101101
- `examples/live_app_server_streaming.exs` — streamed turn over app-server (prints deltas + completion)
102102
- `examples/live_app_server_approvals.exs` — demonstrates command/file approvals, opts into app-server `experimentalApi`, provisions a disposable temp workspace plus temporary `CODEX_HOME`, enables the under-development approval feature flags only inside that isolated home, and prints a structured-grant fallback plus guardian/request-resolution events when live permissions requests still do not appear
103103
- `examples/live_app_server_mcp.exs` — lists MCP servers and prints original vs sanitized qualified tool names

examples/live_app_server_plugins.exs

Lines changed: 40 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ Mix.Task.run("app.start")
33
defmodule CodexExamples.LiveAppServerPlugins do
44
@moduledoc false
55

6+
alias Codex.Protocol.Plugin
7+
68
def main(_argv) do
79
case run() do
810
:ok ->
@@ -33,12 +35,12 @@ defmodule CodexExamples.LiveAppServerPlugins do
3335
) do
3436
try do
3537
with :ok <- ensure_plugin_read_supported(conn),
36-
{:ok, %{"marketplaces" => marketplaces} = list_result} <-
38+
{:ok, %Plugin.ListResponse{marketplaces: marketplaces} = list_result} <-
3739
call_with_timeout(
3840
fn ->
3941
request_or_skip(
40-
Codex.AppServer.plugin_list(conn, cwds: [fixture.repo_root]),
41-
"plugin"
42+
Codex.AppServer.plugin_list_typed(conn, cwds: [fixture.repo_root]),
43+
"plugin/list"
4244
)
4345
end,
4446
10_000,
@@ -47,13 +49,13 @@ defmodule CodexExamples.LiveAppServerPlugins do
4749
{:ok, marketplace, plugin_summary} <-
4850
pick_demo_plugin(marketplaces, list_result, fixture),
4951
marketplace_path when is_binary(marketplace_path) <-
50-
Map.get(marketplace, "path"),
51-
plugin_name when is_binary(plugin_name) <- Map.get(plugin_summary, "name"),
52-
{:ok, %{"plugin" => plugin_detail}} <-
52+
marketplace.path,
53+
plugin_name when is_binary(plugin_name) <- plugin_summary.name,
54+
{:ok, %Plugin.ReadResponse{plugin: plugin_detail}} <-
5355
call_with_timeout(
5456
fn ->
5557
request_or_skip(
56-
Codex.AppServer.plugin_read(conn, marketplace_path, plugin_name),
58+
Codex.AppServer.plugin_read_typed(conn, marketplace_path, plugin_name),
5759
"plugin/read"
5860
)
5961
end,
@@ -230,9 +232,13 @@ defmodule CodexExamples.LiveAppServerPlugins do
230232
end
231233
end
232234

233-
defp pick_demo_plugin(marketplaces, %{"remoteSyncError" => remote_sync_error}, fixture)
235+
defp pick_demo_plugin(
236+
marketplaces,
237+
%Plugin.ListResponse{remote_sync_error: remote_sync_error},
238+
fixture
239+
)
234240
when is_list(marketplaces) do
235-
case Enum.find(marketplaces, &(Map.get(&1, "path") == fixture.marketplace_path)) do
241+
case Enum.find(marketplaces, &(&1.path == fixture.marketplace_path)) do
236242
nil when is_binary(remote_sync_error) and remote_sync_error != "" ->
237243
{:skip,
238244
"plugin/list did not surface the temporary marketplace and reported remoteSyncError: #{remote_sync_error}"}
@@ -241,8 +247,8 @@ defmodule CodexExamples.LiveAppServerPlugins do
241247
{:skip,
242248
"plugin/list did not surface the temporary repo-local marketplace; this build may lack repo marketplace discovery parity"}
243249

244-
%{"plugins" => plugins} = marketplace when is_list(plugins) ->
245-
case Enum.find(plugins, &(Map.get(&1, "name") == fixture.plugin_name)) do
250+
%Plugin.Marketplace{plugins: plugins} = marketplace ->
251+
case Enum.find(plugins, &(&1.name == fixture.plugin_name)) do
246252
nil ->
247253
{:skip,
248254
"plugin/list returned the temporary marketplace but not the expected demo plugin"}
@@ -254,48 +260,44 @@ defmodule CodexExamples.LiveAppServerPlugins do
254260
end
255261

256262
defp pick_demo_plugin(marketplaces, _response, fixture) when is_list(marketplaces) do
257-
pick_demo_plugin(marketplaces, %{}, fixture)
263+
pick_demo_plugin(marketplaces, %Plugin.ListResponse{}, fixture)
258264
end
259265

260-
defp print_plugin_detail(%{} = plugin, fixture) do
261-
summary = Map.get(plugin, "summary") || %{}
262-
interface = Map.get(summary, "interface") || %{}
263-
skills = Map.get(plugin, "skills") || []
264-
apps = Map.get(plugin, "apps") || []
265-
mcp_servers = Map.get(plugin, "mcpServers") || Map.get(plugin, "mcp_servers") || []
266-
267-
needs_auth =
268-
Map.get(summary, "needsAuth") ||
269-
Map.get(summary, "needs_auth") ||
270-
Map.get(plugin, "needsAuth") ||
271-
Map.get(plugin, "needs_auth")
266+
defp print_plugin_detail(%Plugin.Detail{} = plugin, fixture) do
267+
summary = plugin.summary
268+
interface = summary.interface
269+
skills = plugin.skills
270+
apps = plugin.apps
271+
mcp_servers = plugin.mcp_servers
272+
needs_auth = Enum.any?(apps, & &1.needs_auth)
272273

273274
IO.puts("""
274275
App-server plugin/read demo completed.
275276
fixture_root: #{fixture.temp_root}
276277
app_server_cwd: #{fixture.repo_root}
277278
isolated_codex_home: #{fixture.codex_home}
278-
marketplace: #{Map.get(plugin, "marketplaceName")}
279-
marketplace_path: #{Map.get(plugin, "marketplacePath")}
280-
id: #{Map.get(summary, "id")}
281-
name: #{Map.get(summary, "name")}
282-
display_name: #{Map.get(interface, "displayName") || Map.get(summary, "name")}
283-
installed: #{inspect(Map.get(summary, "installed"))}
284-
enabled: #{inspect(Map.get(summary, "enabled"))}
285-
install_policy: #{inspect(Map.get(summary, "installPolicy"))}
286-
auth_policy: #{inspect(Map.get(summary, "authPolicy"))}
279+
marketplace: #{plugin.marketplace_name}
280+
marketplace_path: #{plugin.marketplace_path}
281+
id: #{summary.id}
282+
name: #{summary.name}
283+
display_name: #{(interface && interface.display_name) || summary.name}
284+
installed: #{inspect(summary.installed)}
285+
enabled: #{inspect(summary.enabled)}
286+
install_policy: #{inspect(summary.install_policy)}
287+
auth_policy: #{inspect(summary.auth_policy)}
287288
needs_auth: #{inspect(needs_auth)}
288289
""")
289290

290291
IO.puts("""
291292
Note: this example launches `codex app-server` with an isolated child `cwd` and
292293
temporary `CODEX_HOME`, so it never touches your real Codex config. Because it only
293294
exercises `plugin/list` + `plugin/read` and does not install the plugin, `installed`
294-
and `enabled` should usually remain false. When upstream includes `needsAuth`, that
295-
field is surfaced verbatim so you can see whether an install would require auth.
295+
and `enabled` should usually remain false. The typed response projects app auth
296+
requirements onto `needs_auth` while still preserving unknown upstream fields in
297+
each struct's `extra` map.
296298
""")
297299

298-
if description = Map.get(plugin, "description") do
300+
if description = plugin.description do
299301
IO.puts("Description:\n#{description}\n")
300302
end
301303

@@ -305,7 +307,7 @@ defmodule CodexExamples.LiveAppServerPlugins do
305307
IO.puts(" (none)")
306308
else
307309
Enum.each(skills, fn skill ->
308-
IO.puts(" - #{skill["name"]}: #{skill["description"]}")
310+
IO.puts(" - #{skill.name}: #{skill.description}")
309311
end)
310312
end
311313

@@ -316,7 +318,7 @@ defmodule CodexExamples.LiveAppServerPlugins do
316318
else
317319
Enum.each(apps, fn app ->
318320
IO.puts(
319-
" - #{app["name"] || app["id"]} (id=#{app["id"]}, installUrl=#{inspect(app["installUrl"])})"
321+
" - #{app.name || app.id} (id=#{app.id}, installUrl=#{inspect(app.install_url)}, needs_auth=#{inspect(app.needs_auth)})"
320322
)
321323
end)
322324
end

guides/01-getting-started.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,12 @@ permissions approvals, `mcpServer/startupStatus/updated`, guardian review notifi
156156
`service_name`, and `service_tier` are forwarded on app-server transport, and
157157
per-turn `service_tier` can be passed to `Codex.Thread.run/3`.
158158

159+
The plugin APIs now have both raw and typed forms. Use the raw wrappers when you
160+
want the original response maps, or `Codex.AppServer.plugin_list_typed/2`,
161+
`plugin_read_typed/3`, and `Codex.AppServer.request_typed/5` with
162+
`Codex.Protocol.Plugin.*` when you want typed structs with preserved `extra`
163+
metadata.
164+
159165
When you need the managed app-server child to run against a temporary repo or
160166
isolated `CODEX_HOME`, pass `cwd:` and `process_env:` to
161167
`Codex.AppServer.connect/2`. Thread working directories are still configured per

guides/02-architecture.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ historical mailbox-facing session API on top of `CliSubprocessCore.RawSession`.
2525
The app-server path is the parity transport for upstream v2 features such as `fs/*`, `plugin/read`,
2626
`thread/shellCommand`, structured `item/permissions/requestApproval` responses,
2727
`mcpServer/startupStatus/updated`, guardian review notifications, and `serverRequest/resolved`.
28+
Typed plugin params and responses live locally under `Codex.Protocol.Plugin.*`;
29+
they do not move into the shared runtime-core repos.
2830

2931
Transport selection is per-thread via `Codex.Thread.Options.transport`:
3032

guides/03-api-guide.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,14 +255,18 @@ plugin, and thread-shell helpers:
255255
- `experimental_feature_list/2` and `experimental_feature_enablement_set/2`
256256
- `fs_read_file/2`, `fs_write_file/3`, `fs_create_directory/3`, `fs_get_metadata/2`,
257257
`fs_read_directory/2`, `fs_remove/3`, `fs_copy/4`
258-
- `plugin_list/2`, `plugin_read/3`, `plugin_install/4`, `plugin_uninstall/3`
258+
- raw plugin wrappers: `plugin_list/2`, `plugin_read/3`, `plugin_install/4`, `plugin_uninstall/3`
259+
- typed plugin wrappers: `plugin_list_typed/2`, `plugin_read_typed/3`,
260+
`plugin_install_typed/4`, `plugin_uninstall_typed/3`
261+
- `request_typed/5` for `Codex.Protocol.Plugin.*` request/response structs
259262
- `thread_shell_command/3`
260263

261264
Current app-server lifecycle fields are also forwarded through the high-level
262265
transport: `ephemeral`, `service_name`, and `service_tier` on thread options,
263266
plus per-turn `service_tier` on `Codex.Thread.run/3` / `run_streamed/3`.
264267
`plugin_install/4` and `plugin_uninstall/3` accept `force_remote_sync: true`,
265-
and raw plugin maps preserve newer upstream fields such as `needsAuth`.
268+
raw plugin maps preserve newer upstream fields such as `needsAuth`, and the
269+
typed plugin structs preserve forward-compatible fields in `extra` maps.
266270

267271
`experimental_feature_enablement_set/2` forwards the supplied `enablement` map
268272
without a local allowlist and lets the connected server validate the active

guides/04-examples.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1443,12 +1443,11 @@ end, including base64 round-tripping, and self-skips when the connected build no
14431443
longer advertises those legacy parity methods. `live_app_server_plugins.exs`
14441444
creates a disposable repo-local marketplace and plugin bundle under the system
14451445
temp directory, launches `codex app-server` with an isolated child `cwd` plus a
1446-
temporary `CODEX_HOME`, then demonstrates `plugin/list` discovery followed by
1447-
`plugin/read` detail loading without mutating your real Codex config. Because
1446+
temporary `CODEX_HOME`, then demonstrates the typed `plugin_list_typed/2` and
1447+
`plugin_read_typed/3` wrappers without mutating your real Codex config. Because
14481448
the example never installs or enables that temporary plugin in the isolated home,
1449-
`installed` and `enabled` are expected to remain `false`, `needsAuth` is printed
1450-
when the connected build includes it, and no prior Codex login or plugin install
1451-
is required.
1449+
`installed` and `enabled` are expected to remain `false`, typed app summaries are
1450+
used to derive `needs_auth`, and no prior Codex login or plugin install is required.
14521451
`live_app_server_approvals.exs` demonstrates command/file approvals, uses granular
14531452
`request_permissions: true` for live permissions requests on an `experimental_api: true`
14541453
connection, provisions a disposable temp workspace plus temporary `CODEX_HOME`, enables the

0 commit comments

Comments
 (0)