> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.trysulfur.net/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.trysulfur.net/_mcp/server.

# Getting started

## Plugin layout

A plugin is a directory inside the server's `plugins/` folder containing a `plugin.toml` manifest
and an entrypoint script:

```
plugins/
  my_plugin/
    plugin.toml      # the manifest (required)
    main.star        # the entrypoint (referenced by the manifest)
```

Directories **without** a `plugin.toml` are treated as containers and scanned recursively (up to 8
levels deep), so you can group plugins into folders:

```
plugins/
  mobs/
    vanilla_pig/
      plugin.toml
      main.star
    vanilla_cat/
      plugin.toml
      main.star
  gate_events/
    plugin.toml
    main.star
```

A directory **with** a `plugin.toml` is loaded as a plugin and never recursed into.

## A minimal plugin

```toml title="plugins/hello/plugin.toml"
name = "hello"
version = "0.1.0"
entrypoint = "main.star"
runtime = "starlark"
capabilities = []
```

```python title="plugins/hello/main.star"
# The module body runs ONCE at load. register() captures the hook;
# the server calls it later, on the tick, whenever a player joins.

def on_join(name, entity_id):
    chat("Welcome, %s! (entity %d)" % (name, entity_id))

register("on_player_join", on_join)
```

Drop the directory into `plugins/` and start the server (or just save the file while the server is
running — the hot-reload watcher picks it up). The server logs:

```
plugin host: loaded 1 plugin(s) from plugins/ (hot-reload watcher armed)
```

## The runtime selector

The `runtime` manifest field routes the plugin to its execution lane:

* `runtime = "starlark"` — the entrypoint `.star` file is executed once by the embedded Starlark
  interpreter. Hooks run **inline on the tick goroutine** (fast, sandboxed, deterministic).
* `runtime = "python"` — the entrypoint `.py` file is executed once by embedded CPython **if the
  server was built with `-tags python`**. Hooks are dispatched **off-tick** on a bounded worker
  pool. On a default build the plugin is skipped with a log line:
  `plugin "name": python runtime not built in this binary; skipping`.
* Any other value is rejected loudly:
  `unknown runtime "..." (want "starlark" or "python")`.

## Load semantics

* **The module body runs exactly once.** Everything you want the server to know must be captured
  during that run (via `register`, `declare_mob`, `set_recipe_matcher`, …). After load, module
  globals are frozen.
* **Loading is tolerant per plugin.** The operator scan (`plugins/`) skips a plugin that fails to
  load — bad manifest, Starlark error, unknown capability — logging the reason loudly, and
  continues with the rest. One broken plugin never takes down the others.
* **Errors are loud, never silent.** An unknown event name, capability string, goal flag, base
  type, skill trigger, mechanic kind, or a typo'd entrypoint fails **at load** with a precise
  message. Sulfur's plugin API has a strict "load loudly or skip" discipline: nothing silently
  no-ops.

## Builtins available to every Starlark plugin

| Builtin                | Signature                  | What it does                                                                                                         |
| ---------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `register`             | `register(event_name, fn)` | Subscribe `fn` to a [discrete event](/plugins/events). Unknown event = load error.                                   |
| `log`                  | `log(msg)`                 | Write a line to the server log, prefixed `plugin:`.                                                                  |
| `chat`                 | `chat(msg)`                | Broadcast a system chat message to every player. Falls back to `log()` when no chat sink is wired (e.g. unit tests). |
| `echo`                 | `echo(s)`                  | Returns its string argument (sandbox demo builtin).                                                                  |
| `math`                 | module                     | Starlark's standard math module (`math.cos`, `math.sin`, `math.pi`, `math.sqrt`, …). Pure and deterministic.         |
| `set_recipe_matcher`   | `set_recipe_matcher(fn)`   | Register the crafting matcher — see [Recipes](/plugins/recipes).                                                     |
| `set_recipe_remaining` | `set_recipe_remaining(fn)` | Register the remaining-items callable — see [Recipes](/plugins/recipes).                                             |
| `recipes`              | `recipes()`                | Returns the host-injected vanilla recipe table (or `None`).                                                          |

Mob plugins additionally receive the declaration builtins `declare_mob`, `goal`, `skill`,
`mechanic`, `targeter`, and `condition` — see [declare\_mob](/plugins/declare-mob) and
[Skills](/plugins/skills).

Starlark has no `open`, no network, no `import`, and no way to reach the filesystem. If a plugin
needs data (like the recipe table), the host parses it in Go and hands it in as a frozen value.

## Hot reload

While the server runs, an fsnotify watcher observes `plugins/` and every plugin subdirectory. On
any `.star`/`.toml` write, create, remove, or rename:

1. Events are coalesced behind a **150 ms debounce** (one editor save = one rebuild).
2. A **fresh** plugin manager is built off the tick goroutine by re-scanning the whole directory.
3. On success, the new manager is handed to the tick loop over a swap channel and installed
   **between ticks** — dispatch never observes a half-updated hook table.
4. On failure (e.g. a file saved mid-edit with a syntax error), the rebuild is discarded and the
   last-good plugin set keeps running.

New plugin directories created at runtime are picked up automatically.