Structure and lifecycle
How an agent is laid out, when each part runs, and how it keeps state. Read this before the Manager API: nearly every validation error on a first draft comes from one of the rules on this page.
The shape of an agent
Agent code is the body of async (manager) => { … }. You write the body only: no wrapper, no import, no export. Top-level await is allowed. Already in scope, and never to be imported or redeclared:
manager, the object every capability hangs off (Manager API);- every indicator class and helper (Indicator reference, Helpers);
- every engine type (
Candle,Order,Trade,CreateOrder, …) for annotations (Types); z, the Zod schema builder, formanager.llmresult schemas;DateTime,DurationandIntervalfrom Luxon.
Declaring a name that already exists (const RSI = 5, interface News {}, let manager) fails validation. Give your own declarations distinct names (rsiValue, NewsItem, …).
A complete agent always has the same three parts, in this order:
// 1. Constants and launch settings (root).
const symbol = 'ETH';
const zone = 'UTC';
const period = manager.getVariable('rsiPeriod', 14);
// 2. Data and indicators (root).
const daily = manager.getIntervalData(symbol, 1440, zone);
const rsi = new RSI(daily.close, period);
// 3. Listeners, whose callbacks hold all the logic (root).
manager.onIntervalCandle(symbol, 1440, () => {
if (!rsi.hasValue()) return;
const value = rsi.getValue();
if (value === undefined) return;
if (value < 30) manager.sendNotification(`**ETH** daily RSI is **${value.toFixed(1)}**.`);
}, zone);
Root phase and callback phase
The code runs in two phases, and the validator enforces the split.
Root phase: once, at start-up. The top level of your code. It declares constants, reads launch variables, creates candle series and indicators, and attaches listeners. At this point there is no "now": no current time, no current price, no wallet to trade.
Callback phase: many times, later. Each listener's callback runs when its event fires (a candle closes, a schedule ticks, an order fills, a message arrives). Callbacks read values and act.
| Allowed at the root only | Allowed in callbacks only |
|---|---|
new of any indicator (including History, PartialIndicator) |
manager.getCurrentTime(), manager.getCurrentPrice() |
manager.getIntervalData(...) |
getValue() on an indicator |
attaching listeners: manager.on…(...), manager.cron(...) |
orders and trades: placeOrder, openTrade, closeTrade, closePosition, cancelOrder |
manager.increaseCapital(...), manager.sendNotification(...) |
manager.getVariable works anywhere but belongs at the root. manager.getSymbolMetadata is asynchronous: await it at the root, or call it inside an event callback (onNews, onMessage, onPieuvreEvent), never inside a candle or schedule callback.
Two consequences trip first drafts:
- A listener attached inside a callback is refused. Attach every listener at the root, once. If behaviour depends on a condition, check the condition inside the callback.
- A helper function inherits the scope of its call site. A function that calls
getValue()is fine when called from a callback and refused when called from the root. A function that constructs an indicator must only be called from the root.
Assets are symbols you choose
Code refers to an asset by a short symbol string you pick ('BTC', 'AAPL', 'SPY'). It is not a ticker lookup: when you create the agent you declare which real asset each symbol stands for, and the engine binds it to a concrete listing when the agent runs (a backtest venue, your broker live). Never write an exchange-prefixed ticker or an internal identifier in code.
Keep one symbol per asset and reuse it everywhere. If the agent trades one asset and only reads another (a filter, a correlation), give each its own symbol.
Symbols that the code discovers at run time (a ranking, a position you already hold, a resolved ticker) are opaque trading keys. Pass them to trading and price calls as they are, and never show them to a user: name assets in messages with position.ticker ?? position.symbol, a ranked entry's ticker, or your own declared symbol.
Listeners and what their callbacks receive
| Listener | Fires | Callback argument |
|---|---|---|
manager.onIntervalCandle(symbol, interval, cb, timeZone?) |
when a candle of that interval closes | none: read candles.getValue() |
manager.onIntervalCandleOpen(symbol, interval, cb, timeZone?) |
when a new candle of that interval opens | none: read candles.getPending() |
manager.onPreciseCandle(symbol, cb) |
on every price update | (candle: Candle) |
manager.cron(expression, cb, timeZone?) |
on a cron schedule | none |
manager.onTradeFilled(cb) / manager.onTradeFinished(cb) |
a trade of this agent fills / closes | (trade: Trade) |
manager.onOrderExecuted(cb) / manager.onOrderCanceled(cb) |
an order of this agent executes / is cancelled | (order: Order) |
manager.onNews(request, cb) |
a matching news post arrives | (news: News), i.e. { content } |
manager.onMessage(request, cb) |
a followed account or channel posts | (message: Message) |
manager.onPieuvreEvent(request, cb) |
a matching data update arrives | (event: PieuvreEvent) |
manager.onAltData(identifier, cb) |
an alternative-data point arrives | (point: AltDataPoint<T>) |
A callback may be async. Its errors must not escape: wrap awaited work in try { … } catch (error) { manager.handleError(error); }, and never leave a promise unhandled.
Subscribing to a symbol's prices
The engine only loads prices for symbols the code subscribes to. A symbol used by getIntervalData, onIntervalCandle or onPreciseCandle is subscribed. A symbol you only trade or price from a cron callback is not: subscribe it at the root with an empty listener.
const symbol = 'BTC';
manager.onPreciseCandle(symbol, () => {}); // load BTC prices for the schedule below
manager.cron('0 12 * * *', () => {
const price = manager.getCurrentPrice(symbol);
if (price === undefined) return; // no price yet (market closed, feed not started)
manager.debugLog('BTC at noon', price);
}, 'UTC');
Symbols that the code discovers at run time (rankings, resolved tickers, positions you hold) are subscribed by the engine when they are returned or traded, so they need no empty listener.
Intervals and time zones
An interval is a number of minutes (1, 5, 15, 60, 240, 1440), or 'week' / 'month'. Write 60 for hourly and 1440 for daily; there is no '1h' or '1d'.
Time zones are IANA names ('America/New_York', 'Europe/Paris', 'UTC'). Two separate rules:
- Candles of a day or longer (
1440,'week','month') must state the asset's exchange time zone on bothgetIntervalDataand the listener, or the validator refuses them:'America/New_York'for US stocks and ETFs,'Europe/Paris'for Euronext,'UTC'for 24/7 crypto. A UTC day boundary would split an exchange session in two. - Schedules (
manager.cron) default to UTC. Pass the zone of the wall-clock time you mean:manager.cron('0 9 * * 1', cb, 'Europe/Paris')is 09:00 Paris, winter and summer.
Intraday candles need no zone.
Reading candles and indicators
manager.getIntervalData(symbol, interval, timeZone?) returns the candle series. Inside a callback, candles.getValue() is the last closed candle, { openTime, closeTime, open, high, low, close, volume }, times in epoch milliseconds. candles.close, candles.high, candles.volume, candles.hl2, candles.hlc3 and candles.ohlc4 are ready-made number streams to feed indicators.
Every indicator follows the same protocol:
hasValue()is false until the indicator has been fed once. It is a type guard: after it,getValue()is typed withoutundefinedwrapping.- Many indicators still return
number | undefined(or objects with| undefinedfields) during their warm-up. Checkvalue === undefined(or each field you use) before arithmetic. - Never use
!to silence a missing value, and never coerce it to0.
The Indicator reference gives each class's value type and warm-up length.
Looking back
new History(indicator, n) keeps the last n values, oldest first. It is the tool for "the previous candle", "the highest high of the last 20 candles" and "two consecutive closes".
const symbol = 'SPY';
const zone = 'America/New_York';
const daily = manager.getIntervalData(symbol, 1440, zone);
const last21 = new History(daily, 21);
manager.onIntervalCandle(symbol, 1440, () => {
if (!last21.hasValue()) return;
const window = last21.getValue();
if (window.length < 21) return; // still filling up
const previous20 = window.slice(0, -1); // exclude the current candle
const current = window[window.length - 1];
if (current === undefined) return;
const highest = Math.max(...previous20.map((c) => c.high));
if (current.close > highest) manager.debugLog('20-day breakout', current.close);
}, zone);
Compare the current value with a window that excludes it: the current close can never exceed a maximum that includes it.
Several timeframes and several assets
Create one series per symbol and interval at the root. In a callback, every other series may not have ticked yet (another market's hours, a later listing, a higher timeframe still forming): guard each one with hasValue() before reading it. A higher-timeframe series read from a lower-timeframe callback returns its last closed candle.
const symbol = 'BTC';
const zone = 'UTC';
const daily = manager.getIntervalData(symbol, 1440, zone);
const hourly = manager.getIntervalData(symbol, 60);
const trend = new EMA(daily.close, 200);
const rsi = new RSI(hourly.close, 14);
manager.onIntervalCandle(symbol, 60, () => {
if (!trend.hasValue() || !rsi.hasValue() || !daily.hasValue()) return;
const ema200 = trend.getValue();
const rsiValue = rsi.getValue();
if (ema200 === undefined || rsiValue === undefined) return;
const uptrend = daily.getValue().close > ema200;
if (uptrend && rsiValue < 30) manager.debugLog('pullback in an uptrend', rsiValue);
});
For several assets on the same schedule, prefer one listener that loops over the symbols over one listener per symbol: it keeps the logic, the budget and the notifications in one place.
State that survives restarts
Local variables live only as long as the process. The engine restarts agents (deployments, maintenance), and on restart the root runs again, candles and indicators are rebuilt from history, and local variables start from scratch. Anything that must survive goes in the status bag:
interface CounterState {
alerts?: number;
lastAlertTime?: number;
}
const symbol = 'BTC';
const hourly = manager.getIntervalData(symbol, 60);
manager.onIntervalCandle(symbol, 60, () => {
if (!hourly.hasValue()) return;
const state = manager.getStatus<CounterState>('default');
const candle = hourly.getValue();
if (state.lastAlertTime === candle.closeTime) return; // already handled this candle
state.lastAlertTime = candle.closeTime;
state.alerts = (state.alerts ?? 0) + 1;
if (state.alerts >= 3) manager.stop('three alerts sent');
});
manager.getStatus<T>(key)returns a persistent object for that key. Mutate it in place; the engine saves it.- Every field is optional (the bag starts as
{}), so declare them all with?and initialise before use. Pass your shape as the type argument; never cast. - Keep it small and JSON-shaped: numbers, strings, booleans, arrays, plain objects. Trim lists you append to.
- Saves are periodic, not transactional. After an abrupt crash the last few seconds of changes can be lost, so an agent that places orders should also check the real book (
manager.getPositions(),manager.getSymbolPendingOrders()) rather than trust the status alone.
Use state only for what must span callbacks. A condition evaluated within one candle needs no state.
Launch variables
manager.getVariable(name, default) reads a setting the user can change when starting a backtest or a live run, without editing the code: an indicator period, a threshold, a size.
const rsiPeriod = manager.getVariable('rsiPeriod', 14); // number
const mode = manager.getVariable('mode', 'long'); // string
const trail = manager.getVariable('useTrailing', false); // boolean
Rules the validator enforces:
- Both arguments are literals: the name matches
[A-Za-z][A-Za-z0-9_]{0,63}, the default is a finite number, a string or a boolean. No expressions. - Call it directly on
manager(no alias, no destructuring, no stored method reference), once per name, into a rootconst, and pass the value around. - A value that feeds an indicator period or length must be a whole number, so its default must be an integer.
- The default is canonical: a run that sets nothing behaves exactly as the code reads. The returned value always has the default's type.
Make only the knobs a user would realistically tune into variables (a handful, 16 at most). Structure (which indicator, which schedule, which asset) stays in code.
Time
Never read the wall clock: Date.now(), new Date() without an argument, DateTime.now(), DateTime.local() and DateTime.utc() are refused, because a backtest replays the past. Use manager.getCurrentTime() (in a callback) or a candle's openTime / closeTime, and build dates from them:
const now = DateTime.fromMillis(manager.getCurrentTime()).setZone('America/New_York');
if (now.weekday > 5) return; // Saturday or Sunday in New York
setTimeout and setInterval are refused too: schedule with manager.cron.
How many times should it act?
Decide before writing the code:
- Once. Act, then call
manager.stop()in the same callback. Without it the listener keeps firing.stop()does not cancel pending orders or close trades: do that first if needed. - N times. Count in the status bag and call
manager.stop()at N. - Every time the condition becomes true. Latch the state in the status bag so a condition that stays true does not re-fire on every candle, and re-arm when it clears.
- Forever. A strategy or a recurring alert: never stop. The user controls it.
interface LatchState { below?: boolean }
const symbol = 'NVDA';
const zone = 'America/New_York';
const daily = manager.getIntervalData(symbol, 1440, zone);
const ema = new EMA(daily.close, 200);
manager.onIntervalCandle(symbol, 1440, () => {
if (!ema.hasValue() || !daily.hasValue()) return;
const average = ema.getValue();
if (average === undefined) return;
const state = manager.getStatus<LatchState>('default');
const isBelow = daily.getValue().close < average;
if (isBelow && state.below !== true) {
state.below = true;
manager.sendNotification(`**NVDA** closed below its 200-day average (**$${average.toFixed(2)}**).`);
} else if (!isBelow && state.below === true) {
state.below = false; // re-arm for the next crossing
}
}, zone);
Errors and diagnostics
manager.handleError(error)reports a caught error without crashing the run. Give it the error, never a promise.manager.debugFlag('label')records that a line was reached;manager.debugLog(...values)records values. Both show in backtest results and help answer "why did it never trade?". Put a flag before each earlyreturnthat can swallow the expected action. Labels are string literals.manager.comment(text)writes a note to the agent's journal, visible to the user in live runs too.
The lifecycle outside the code
- Write the code, using these pages.
- Validate it (
validate_agent_codeover MCP): free, deterministic, runs nothing. Iterate until it passes. See Validation rules. - Create the agent (
create_agent) with a plain-language description and, optionally, your code. Obside's builder reimplements it into a reviewed agent; you never run code directly on Obside. - Backtest it, then run it on a demo portfolio or live.
Not every agent can be backtested: one that reads your live portfolio, calls an AI model on a schedule, reads news or waits for events can only be validated in a demo run. See Pitfalls.