Writing Machines
State, actions, config, and lifecycle hooks.
The BaseMachine class
Every machine extends BaseMachine from @vintage/core. The class handles
state management, event broadcasting, and action registration — you only need to
define the shape of your machine.
import { BaseMachine } from "@vintage/core";
class MyMachine extends BaseMachine<MyState> {
__NAME = "my-machine";
protected initialState: MyState = { ... };
actions = { ... };
}
export default MyMachine;__NAME is the machine's identifier. It's used as the URL path segment for the
REST API and WebSocket messages.
State
State is a plain TypeScript object. All keys must be JSON-serializable by default.
interface SensorState {
temperature: number
humidity: number
status: "idle" | "reading" | "error"
}Use updateState() to apply a partial update. It merges the patch and
broadcasts the new state to all connected clients:
this.updateState({ temperature: 23.5 })Private state
Keys prefixed with _ are stripped before broadcasting. Use them to hold
non-serializable values like hardware handles, timers, or open connections:
interface SensorState {
temperature: number
_serialPort: SerialPort // never sent over the wire
}Actions
Actions are the public interface of your machine — they appear in the web UI and are callable via the REST API.
No-argument actions
Use createNoArgAction for actions that take no input:
actions = {
read: this.createNoArgAction(async () => {
const value = await this.readSensor()
this.updateState({ temperature: value })
}, "Trigger a sensor read"),
}Actions with input
Use createAction with a Zod schema to validate input. The schema is also used
to render form controls in the UI:
import { z } from "zod"
actions = {
setThreshold: this.createAction(
z.object({ value: z.number().min(-50).max(150) }),
({ value }) => {
this.updateState({ threshold: value })
},
"Set the alert threshold (°C)",
),
}Configuration
Machines can accept external configuration — useful for hardware pin numbers, connection strings, or per-instance settings.
Define a Zod schema as a static property:
import { z } from "zod"
class SensorMachine extends BaseMachine<SensorState, {}, SensorConfig> {
__NAME = "sensor"
static configSchema = z.object({
pin: z.number(),
pollInterval: z.number().default(1000),
})
}Config is validated at startup and accessible via this.config:
async init() {
this._poller = setInterval(() => this.readSensor(), this.config.pollInterval);
}Pass config at startup:
vintage serve sensor.ts --config config.jsonLifecycle hooks
init()
Called once when the machine starts. Use it to open connections, start timers, or run homing sequences:
async init() {
this._port = await SerialPort.open(this.config.portPath);
this.emitInfo("Serial port open");
}kill()
Called when the machine shuts down. Clean up resources here:
async kill() {
clearInterval(this._poller);
await this._port.close();
}Emitting events
Beyond state updates, machines can emit informational messages, warnings, and errors. These are broadcast over the WebSocket and visible in the web UI:
this.emitInfo("Sensor calibrated")
this.emitWarn("Temperature approaching threshold")
this.emitError("Sensor read timeout")Full example
import { BaseMachine } from "@vintage/core"
import { z } from "zod"
interface SensorState {
temperature: number
threshold: number
_poller: ReturnType<typeof setInterval> | null
}
type SensorConfig = { pollInterval: number }
class TemperatureSensor extends BaseMachine<SensorState, {}, SensorConfig> {
__NAME = "temperature-sensor"
static configSchema = z.object({
pollInterval: z.number().default(2000),
})
protected initialState: SensorState = {
temperature: 0,
threshold: 80,
_poller: null,
}
async init() {
this.state._poller = setInterval(
() => this.poll(),
this.config.pollInterval,
)
}
async kill() {
if (this.state._poller) clearInterval(this.state._poller)
}
private async poll() {
const temp = await readThermocoupleI2C()
this.updateState({ temperature: temp })
if (temp > this.state.threshold) {
this.emitError(`Temperature exceeded threshold: ${temp}°C`)
}
}
actions = {
setThreshold: this.createAction(
z.object({ value: z.number() }),
({ value }) => this.updateState({ threshold: value }),
"Set alert threshold (°C)",
),
}
}
export default TemperatureSensor