A function node is your own Elixir runtime, running on its own Fly.io machine, that you can load code into and call. You write a module, send it to the node, and it is compiled and hot-loaded into the running BEAM in about a hundred milliseconds. From then on the module is callable by name, from a tool call or from a workflow step.
The point is separation. Your code runs somewhere other than the gateway, on a machine that holds no enterprise credentials. The only thing it carries is a scoped sub-token, so anything the code wants to reach in the outside world has to come back through the gateway and be checked there.
Provisioning an instance
Go to /function-nodes and fill in the form, or call the tool:
{
"tool": "function_node_provision",
"instance_name": "bob-tools",
"scope": [190001, 210004],
"ttl_days": 365,
"volume_size_gb": 5
}
Four things to decide:
Instance name. Lowercase letters, digits and hyphens, 2 to 32 characters, unique per owner. The gateway derives a globally unique Fly app name from it (fn-<owner>-<name>-<random>), so the instance reachable at https://fn-bob-tools-a3f91c.fly.dev is yours alone.
Scope. The permission keys the node’s sub-token will carry. This is the blast radius: the maximum set of gateway tools that code on this node can reach, no matter what the code does. The list is intersected with your own permissions before the token is minted, so a node can never hold a key you do not hold yourself. Give it the narrowest set that lets the job run.
TTL. How long the sub-token stays valid, in days. Blank means 365.
Persistent storage. None by default. Attaching a volume of 1, 5 or 25 GB gets you a disk mounted at /data that survives machine restarts.
Provisioning is asynchronous. The call returns immediately with status provisioning while the gateway creates the Fly app, allocates a shared IPv4, creates the volume if you asked for one, boots the machine and polls its /health endpoint until it answers. That normally takes about a minute. The status flips to ready, or to failed with the reason recorded. If a provision times out purely because DNS had not propagated yet, the Retry button on the instance page checks the machine directly through Fly and flips the row to ready when it is genuinely healthy.
Deploying a module
Send source, get a compiled module:
{
"tool": "function_node_compile",
"instance_name": "bob-tools",
"module": "Mathy",
"source": "defmodule Mathy do\n def double(n), do: n * 2\nend"
}
The instance page has the same thing as an editor with a Deploy button. The node parses the source, validates it against the sandbox rules below, compiles it in an isolated task and records its exports. On success the gateway also persists the source, which is what makes the module survive a restart.
Calling a function
{
"tool": "function_node_call",
"instance_name": "bob-tools",
"module": "Mathy",
"function": "double",
"args": [21]
}
You get back {"ok": true, "result": 42}. function_node_modules lists what is currently loaded on the node, with each module’s exported functions and arities.
Because this is an ordinary gateway tool, a workflow step can call it. Put function_node_call in a step’s tool field, feed it arguments from an earlier step, and your own Elixir runs as one node in the graph.
What your code is allowed to do
The real enforcement is the sub-token. The sandbox on the node is defence in depth: it stops user code from trivially wrecking the machine it runs on. Code that references any of the following is refused at compile time, with the offending references listed in the error.
Blocked modules: System, File, Port, Code, IO.ANSI, Task.Supervisor, and the Erlang modules :os, :file, :filelib, :httpc, :ssh, :gen_tcp, :gen_udp, :inet, :c, :shell.
Blocked calls: bare spawn, send, self, exit, throw, apply, binary_to_term, binary_to_atom, list_to_atom; String.to_atom; process and concurrency primitives on Process, GenServer, Task, Agent, Registry; and Application.get_env and friends.
Two limits apply at runtime. A compile has 10 seconds, a call has 30 seconds, and both run under a heap cap of roughly 50 MB. Exceeding any of them kills the task and returns a timeout rather than taking the node down.
So: pure computation, data shaping, business rules. Anything that touches the world goes through the gateway.
Reaching the outside world
That is what WA.tool/2 is for:
defmodule Notifier do
def alert(msg) do
WA.tool("pushover_send", %{"message" => msg})
end
end
The call is a JSON-RPC tools/call sent back to the gateway over HTTPS, authenticated with the node’s sub-token. The gateway checks the token’s scope, runs the tool, and returns the result. You get {:ok, result}, or {:error, {:not_allowed, reason}} when the tool is outside the node’s scope. That error is the enforcement layer working as designed, not a bug to route around.
This is the whole model in one line: the node computes, the gateway acts.
Keeping state
Two options, for different shapes of data.
Key/value on the gateway. Available to user code as three functions, namespaced per module so two modules on the same node cannot collide:
FunctionNodeRuntime.State.put(Mathy, "last_run", %{count: 42})
{:ok, value} = FunctionNodeRuntime.State.get(Mathy, "last_run")
FunctionNodeRuntime.State.delete(Mathy, "last_run")
Values are Erlang terms, serialised on the node and stored as opaque bytes by the gateway, with a 4 MB ceiling per value. Because it lives on the gateway rather than the machine, it survives the node being destroyed and rebuilt. You can inspect it from outside with function_node_state_list and function_node_state_get.
A persistent volume. If you provisioned one, it is mounted at /data and reached through helpers rather than raw file calls:
{:ok, dir} = FunctionNodeRuntime.Volume.namespace_dir(Mathy) # /data/modules/Mathy
{:ok, path} = FunctionNodeRuntime.Volume.sqlite_path("notes") # /data/sqlite/notes.db
Volume.available?/0 tells you whether a volume is attached. Use the key/value store for small facts you want to outlive the instance, and a volume for anything file-shaped or large.
Restarts and cost
Machines are configured to stop when idle and start again on the next request, with no minimum running. A node you are not using costs nothing but its volume.
When the machine wakes, the runtime fetches every module source you have deployed to that instance and recompiles them before serving traffic. Modules therefore survive idle stops, host reboots and image updates without you redeploying. There is a sub-second window right after boot where a call can return module_not_loaded while that replay is still running; retry and it will be there.
Watching and tearing down
function_node_health checks liveness and uptime. function_node_list shows every instance you own with its status, URL and volume. function_node_sources returns the persisted source bundle, so you can always see exactly what is deployed.
Every provision, compile, call and destroy is written to an audit log with its arguments, result status and duration, readable through function_node_audit or the instance’s audit page.
function_node_destroy revokes the sub-token first, then destroys the Fly app and its volumes, then deletes the stored sources and state. The token dies before the machine does, so a node in the middle of being torn down cannot make one last call.
What the gateway needs
Function nodes require the function_nodes permission, and the gateway instance needs a Fly API token with org-admin rights plus flyctl on its path for IPv4 allocation. Machines default to one shared CPU and 256 MB of memory, which is ample for the computation this is meant for and not meant for anything heavier.
In short
Provision a named instance with the narrowest scope that works. Deploy plain Elixir modules to it and call them by name from tools or workflows. Compute freely on the node, and let anything that touches the world go back through the gateway, where the sub-token decides what is allowed. Keep small facts in the key/value store and larger things on a volume. Destroy the instance when the work is done.