SDK Automation Pipeline Guide
How the com.n3plugins.sdk.* automation surface fits together, which tool to
reach for per task type, and how to intercouple them. Written as a reference for
both suite contributors and plugin authors consuming the SDK.
Every claim and code shape here is drawn from the live source as of 2026-06-07. The worked examples are real game flows - bank kit withdrawal, fletching, jewelry enchanting - each cross-linked to its dedicated reference page.
1. The layered model
┌────────────────────────────────────────────────────────────┐
│ TASK DRIVER LAYER │
│ AutomationStateMachine │ TaskPipeline │
│ (branching / looping) │ (linear ordered sequences) │
├────────────────────────────────────────────────────────────┤
│ DOMAIN BUILDER LAYER │
│ CombatWorkflowBuilder ProductionWorkflowBuilder │
│ BankWorkflowBuilder EnchantingWorkflowBuilder │
│ AbstractWorkflowBuilder (base; compiles to StepHandler) │
├────────────────────────────────────────────────────────────┤
│ LOADOUT LAYER │
│ InventoryLoadout EquipmentLoadout LoadoutItem │
│ (declare what to carry / wear for bank reconcile cycles) │
├────────────────────────────────────────────────────────────┤
│ PRECONDITION LAYER │
│ InventoryPlan - assert inventory before a step │
│ Equipment.search() - assert equipment before a step │
│ Cooldowns / TickDelay - timing gates │
├────────────────────────────────────────────────────────────┤
│ PACING LAYER │
│ ActionPacer (suite-wide; advanced by PacketUtilsPlugin) │
├────────────────────────────────────────────────────────────┤
│ INTERACTION LAYER │
│ AutomationApi (16 sub-APIs) + InteractionApi.actions.* │
│ every action returns InteractionResult │
└────────────────────────────────────────────────────────────┘
A plugin almost never touches all layers at once. The normal shape is: a task driver sequences interaction-layer calls, optionally guarded by the precondition layer, with loadouts feeding the bank/restock builders. The pacing layer is invisible to the driver - see §3.
2. Where pacing lives (read this before using a pipeline)
ActionPacer is static and suite-wide. PacketUtilsPlugin.onGameTick
advances it once per tick via ActionPacer.onTick(System.currentTimeMillis())
(see suite-wiring.md). Plugins do not advance it.
The gate is consumed inside the interaction layer, not by your driver. The
canonical shape, found throughout InteractionApi.actions.*, is:
long nowMs = System.currentTimeMillis();
if (!ActionPacer.isReady(nowMs)) {
return InteractionResult.fail(InteractionStatus.PACED, "paced");
}
// ... dispatch the packet ...
ActionPacer.recordAction();
return InteractionResult.success(...);
So when a paced action is blocked, it returns an InteractionResult whose status
is InteractionStatus.PACED. Your driver never calls isReady/recordAction
itself - it only sees the InteractionResult and reacts to it.
The InteractionResult → StepResult bridge has one sharp edge
StepResult.fromInteraction(result) maps only SUCCESS → success and
everything else → failed (verified in StepResult.java):
public static StepResult fromInteraction(InteractionResult result) {
if (result == null) return failed("Interaction result is null");
if (result.succeeded()) return success(result.getMessage());
return failed(result.getMessage());
}
InteractionResult.succeeded() is status == SUCCESS - nothing else. So PACED,
TARGET_NOT_FOUND, WIDGET_HIDDEN, and a real failure all collapse to
StepStatus.FAILED. The bridge cannot tell "paced, try again next tick" from
"broken."
That is survivable because of how the pipeline treats FAILED - see next.
TaskPipeline.tick() never self-aborts
From TaskPipeline.java, tick() only branches on three statuses:
StepResult status | TaskPipeline.tick() effect |
|---|---|
SUCCESS | index++ - advance to next step |
WAIT | arm an internal TickDelay(delayTicks); re-run this step after it expires |
RESET | index = 0 |
RETRY | (no branch) - index unchanged, re-run same step next tick |
FAILED | (no branch) - index unchanged, re-run same step next tick |
So FAILED and RETRY are identical to the pipeline: stay on the current step
and re-run it next tick. A PACED action therefore retries automatically until
the gate clears, then succeeds and advances - exactly what we want, achieved via
FAILED, not via any pacing awareness in the pipeline.
The cost of the sharp edge: because a genuine failure also just re-runs
forever, the pipeline will never give up on its own. The driver that owns the
pipeline must watch pipeline.lastResult(), pipeline.currentStepTicks(),
and/or pipeline.currentStepAttempts() and halt() itself. This is exactly what
TutorialIslandController already does with its ticksOnCurrentVarp > 60 and
nameWidgetAbsentTicks > 20 guards.
If you need a step to distinguish "paced/transient" from "abort," use the result-aware bridge that matches the step's intent:
StepResult.fromInteractionPaced(result)retries onlyPACED.StepResult.fromInteractionTransient(result)retries the SDK default transient set:PACED, target/widget temporarily missing or hidden, and bank/deposit-box/production interfaces not open yet.StepResult.fromInteractionRetrying(result, statuses...)lets the caller choose the exact statuses that should becomeRETRY.
Functionally retry and failed behave the same inside TaskPipeline, but the
distinction is what your driver's halt logic reads off lastResult().
3. Choosing a task driver
| The work is… | Use | Why |
|---|---|---|
| A fixed-order multi-tick sub-sequence (click → type → confirm) | TaskPipeline | Its index is the sub-step state; no manual counters |
| An observation loop re-evaluated every tick (talk if dialogue up; walk if not; gather until count) | Plain per-tick handler - no driver | State lives in the game, not in your code. A pipeline would fight the re-evaluation |
| Branching with recovery / terminal error+success states | AutomationStateMachine | Guards route between named states; built-in complete()/error() |
| A known domain: combat / production / banking / enchanting | The matching domain builder | Encodes the domain's happy-path; you supply a Services impl |
The single most common mistake is reaching for a driver when the work is an
observation loop. OSRS dialogue and gathering are stateless - see
dialogue-helper.md and the suite rule "if the target widget is visible this
tick, click it; otherwise do nothing." Forcing those into a TaskPipeline adds a
state machine on top of a system that is already a state machine (the game varp),
and the two desync. Keep them as per-tick handlers.
4. Task type: linear multi-tick sub-sequence → TaskPipeline
Shape: a handful of actions that must happen in order, one per tick, where
re-evaluating from scratch each tick would re-fire earlier actions. The pipeline's
internal index is the step state - you never own a counter field.
TaskPipeline is directly instantiable - no subclassing:
TaskPipeline.create()
.step("name", () -> StepResult.fromInteraction(SomeActions.doThing()))
.step("...", () -> StepResult.waitTicks(1, "..."))
// driver calls pipeline.tick() once per game tick
TaskStep is StepResult execute(). StepResult factories:
success(), success(msg), waitTicks(n), waitTicks(n, msg), retry(msg),
failed(msg), reset(msg), fromInteraction(InteractionResult).
Flow 1 - withdraw a bank kit, then equip it
A fixed linear sequence: open the bank, withdraw runes and food, close, then equip
a weapon. Each game-call returns an InteractionResult, bridged with
StepResult.fromInteraction; the wait-bank step uses waitTicks to let the
interface settle.
TaskPipeline withdrawKit() {
return TaskPipeline.create()
.step("open-bank", () -> StepResult.fromInteraction(BankActions.openNearest()))
.step("wait-bank", () -> BankActions.isOpen()
? StepResult.success()
: StepResult.waitTicks(1, "Opening bank"))
.step("withdraw-food", () -> StepResult.fromInteraction(
BankActions.withdraw(ItemID.LOBSTER, 6)))
.step("withdraw-runes",() -> StepResult.fromInteraction(
BankActions.withdraw(ItemID.LAW_RUNE, 20)))
.step("close-bank", () -> StepResult.fromInteraction(BankActions.close()))
.step("equip-weapon", () -> StepResult.fromInteraction(
EquipmentActions.equip("Abyssal whip")));
}
Driver:
private TaskPipeline kit;
@Subscribe
public void onGameTick(GameTick event) {
if (kit == null) kit = withdrawKit();
if (kit.complete()) { kit = null; return; } // done; ready for next run
StepResult r = kit.tick();
status = "Kit: " + kit.currentStepName();
if (r.getStatus() == StepStatus.FAILED && stalledTooLong()) {
// The pipeline never aborts itself (see §2) - the driver decides to give up.
kit = null;
}
}
Why no counter field: the pipeline advances its own index on each SUCCESS
and re-runs the same step on WAIT/RETRY/FAILED. Rebuilding the pipeline
(kit = withdrawKit()) resets the sequence - no manual step bookkeeping. See
task-pipeline.md for the full status table.
Terminal steps are often observation loops, not fire-once actions. If your last step is "attack until in combat" or "wait until the interface closes," it keeps returning a non-
SUCCESSresult and re-runs - it may never reachcomplete(). That is fine: the pipeline owns the fixed-order prefix, and the tail merges into the §5 observation pattern. Let the outer condition (a varp, a count) end it, not the pipeline.
5. Task type: observation loop → plain per-tick handler (no driver)
Shape: every tick, look at the game, do at most one thing. The "progress" is a live inventory count or game var, not a counter you own. This is most of Dialogue Helper and every gather/skill loop.
These need no SDK driver - a plain onGameTick body is correct:
// Gather loop: gate on a live inventory count, not a step index.
// idle() is your own "not currently animating/moving" check.
@Subscribe
public void onGameTick(GameTick event) {
int logs = Inventory.search().withName("Yew logs").count();
if (logs < target && idle()) {
AutomationApi.objects.interact("Yew tree", "Chop down");
}
}
// Dialogue loop: continue if a dialogue is up, else talk. Re-derived each tick.
@Subscribe
public void onGameTick(GameTick event) {
if (AutomationApi.dialogue.isPresent()) {
AutomationApi.dialogue.continueDialogue();
} else if (npcInRange("Banker")) {
AutomationApi.npcs.interact("Banker", "Talk-to");
}
}
Why no pipeline: the player can be interrupted, the dialogue can re-open, the
animation can drop - the game owns the state and the handler reacts. A
pipeline index here would desync from the live state the moment anything
unexpected happens. This is the suite's "per-tick self-limiting" pattern; the
stateless rule is spelled out in dialogue-helper.md.
Pacing still applies - AutomationApi.objects.interact(...) is gated by
ActionPacer at the interaction layer and no-ops on a blocked tick. The
handler doesn't care; it tries again next tick. That is why observation loops need
no pacing plumbing at all.
6. Task type: branching with recovery → AutomationStateMachine
Shape: named states, conditional transitions, real terminal success/error.
Built fluently; driven by pulse() once per tick.
AutomationStateMachineBuilder API:
AutomationStateMachine m = AutomationStateMachineBuilder.create()
.state("validate")
.status("Validating")
.transition("error", ctx -> magicLevel() < required)
.transition("openBank",ctx -> !hasRunes())
.transition("cast", ctx -> true)
.state("openBank")
.status("Opening bank")
.execute(ctx -> openNearestBank())
.transition("error", ctx -> Boolean.TRUE.equals(ctx.get("bankFailed")))
.transition("cast", ctx -> isBankOpen())
.state("cast")
.status("Casting")
.execute(ctx -> castOnce())
.transition("validate", ctx -> true) // loop
.state("error").error()
.state("complete").complete()
.build("validate");
while (m.pulse()) { Delays.tick(); } // or call m.pulse() once per onGameTick
pulse() returns false at a terminal state. AutomationContext carries shared
state plus tick-based deadlines (startCooldown(key, ticks),
isCooldownActive(key), hasTimedOut(key, ticks)) so states can pace themselves
without owning counters.
Real consumer (Flow 3): EnchantingWorkflowBuilder.create(EnchantingPlan)
returns one of these machines (validate → open bank → prepare → cast loop, with
ERROR_MAGIC/ERROR_RUNES/ERROR_BANK terminals). Use it as the reference for any
"do X repeatedly, restock when out, abort on a hard stop" flow. Full state graph
and EnchantingPlan fields: automation-state-machine.md.
Transitions are evaluated in declaration order; first match wins. Put the error/abort guards before the happy-path guard.
7. Task type: domain builders
Each domain builder compiles a happy-path StepHandler (or a state machine for
enchanting) from a Services interface you implement. The builder owns the step
ordering; your Services owns the actual game calls and is the unit-test seam.
7a. Combat → CombatWorkflowBuilder
StepHandler h = new CombatWorkflowBuilder(services)
.findTarget(npc -> "Chicken".equals(npc.getName()))
.attackTarget()
.waitForTargetDeath()
.eatFoodBelow(50, FOOD_ID)
.lootItems(FEATHER_ID)
.build();
while (h.step()) { Delays.tick(); }
Services you must implement:
getCurrentHealthPercent, getSpecEnergy, hasFood/eatFood,
hasPotion/drinkPotion, toggleSpec, enablePrayers/disablePrayers,
findTarget(Predicate<NPC>), attack(NPC), isDead(NPC), loot(int...).
Builder verbs: findTarget, attackTarget, waitForTargetDeath,
eatFoodBelow, drinkPotionIf(BooleanSupplier, int...), toggleSpecAt,
enablePrayersDuring, disablePrayers, lootItems.
Don't over-apply it. A trivial "attack nearest NPC, then idle while in combat" loop with no food, prayer, spec, or loot is a §5 observation loop - wiring a full
Servicesfor it is more code than a two-line handler. Reach for the builder once the eat/loot/target-death cycle is real. Full reference:combat-and-prayer.md.
7b. Production → ProductionWorkflowBuilder
For interfaces that open a production widget (smelting, fletching, crafting):
StepHandler h = new ProductionWorkflowBuilder(services)
.waitForProductionOpen()
.selectOption("Bronze dagger") // or selectOption(index)
.selectQuantity(ProductionQuantity.ALL)
.waitForProductionComplete()
.bankAndRestock(loadout)
.build();
Services: isProductionOpen, selectOption(String|int), selectQuantity,
enterCustomQuantity, isInventoryFull, isProductionStopped,
bankAndRestock(InventoryLoadout). Full reference: production-workflow.md.
Use-item-on-item is NOT this. A direct combine (item-on-item or item-on-object) that never opens a make-X interface - so
isProductionOpen()would never be true - is a §5 observation loop, not a production flow. The production builder is for the make-X menu (fletching, smelting, crafting), where a quantity dialog drives the run.
7c. Banking → BankWorkflowBuilder (+ loadouts)
StepHandler h = BankWorkflowBuilder.create()
.reconcile(inventoryLoadout, equipmentLoadout) // open → deposit → withdraw → equip
.build();
Granular verbs also exist: openNearest, depositInventory, depositEquipment,
withdraw(InventoryLoadout), equip(EquipmentLoadout). create() is a static
factory; unlike the combat/production builders the bank Services is wired
internally (swappable in tests via setServicesForTesting). Full reference:
banking.md.
A hint-arrow-driven booth or any single fixed interaction is not a bank cycle
- those stay §5 observation loops.
BankWorkflowBuilderis for real deposit/withdraw/equip cycles against a loadout (e.g. Market Alcher restock, Tree Woodcutter supply recovery).
8. Loadouts (the declare-vs-assert distinction)
| Type | Role | Fed to |
|---|---|---|
InventoryPlan | Assert - "does inventory satisfy this right now?" | guard before a step |
InventoryLoadout | Declare - "inventory should contain this after a bank run" | BankWorkflowBuilder.withdraw/reconcile, ProductionWorkflowBuilder.bankAndRestock |
EquipmentLoadout | Declare - "this should be worn after restock" | BankWorkflowBuilder.equip/reconcile |
Build a loadout from LoadoutItem (immutable builder):
InventoryLoadout inv = new InventoryLoadout();
inv.add(LoadoutItem.builder(NATURE_RUNE_ID).amount(100).build());
inv.add(LoadoutItem.builder(FIRE_RUNE_ID).amount(400).stackable(true).build());
EquipmentLoadout eq = new EquipmentLoadout();
eq.add(LoadoutItem.builder(STAFF_OF_FIRE_ID)
.slot(EquipmentInventorySlot.WEAPON).build());
Loadout query surface: isFulfilled(), getRequiredItems(),
getMissingItems() / getUnequippedItems() (equipment), getExcessItems(),
getForeignItemIds(), plus an ItemDepletionListener hook. Full reference:
loadouts.md.
Precondition guard with InventoryPlan
InventoryPlan answers a yes/no before you commit to a step:
InventoryPlan plan = InventoryPlan.create() // live snapshot
.require(NATURE_RUNE_ID, 1)
.require(ALCH_TARGET_ID, 1)
.requireFreeSlots(1);
if (!plan.satisfied()) {
// describeMissing() → human-readable; branch to restock
}
InventoryPlan.create() reads a live snapshot; create(InventorySnapshot) takes
a fake for tests. Equipment has no EquipmentPlan - assert it with the query
layer instead:
boolean staffOn = Equipment.search().withName("Staff of fire").exists();
(EquipmentItemQuery: withId, withName, nameContains*, withAction,
exists, count, first, single, result, …)
9. Choosing wrong: the anti-patterns
- Don't wrap an observation loop in a driver. Gather/dialogue/skill loops are
re-evaluated every tick by design; a
TaskPipelineindexdesyncs from the game's own state (§5). - Don't reach for a domain builder when a two-line handler suffices. A trivial
attack/interact with no eat/loot/restock cycle does not need
Serviceswiring (§7a). - Don't route use-item-on-item through
ProductionWorkflowBuilder. The builder handles the make-X menu, not direct combines (§7b). - Don't treat a single fixed interaction as a bank cycle.
BankWorkflowBuilderearns its keep only across deposit/withdraw/equip against a loadout (§7c). - Don't call
ActionPacerfrom your driver. The interaction layer consumes it;InteractionStatus.PACEDis the only contact you have (§2).
10. Quick reference
| You have… | Reach for | Drives via |
|---|---|---|
| Fixed-order multi-tick sub-steps | TaskPipeline | tick() per game tick |
| Observation loop (game owns state) | plain per-tick handler | your onGameTick |
| Branching + recovery + terminal states | AutomationStateMachine | pulse() |
| Combat with eat/loot/target cycle | CombatWorkflowBuilder | StepHandler.step() |
| Make-X production interface | ProductionWorkflowBuilder | StepHandler.step() |
| Real bank restock cycle | BankWorkflowBuilder + loadouts | StepHandler.step() |
| Repeated enchant/cast with restock | EnchantingWorkflowBuilder | AutomationStateMachine.pulse() |
| "Do I have the items right now?" | InventoryPlan | satisfied() |
| "Is this equipped right now?" | Equipment.search() | exists() |
| Per-key tick cooldown | Cooldowns | ready(key) / delay(key,n) / tick() |
| Single tick delay | TickDelay | ready() / delay(n) / tick() |
Pacing is never your concern at the driver level: it lives in the interaction
layer, surfaces as InteractionStatus.PACED, and (via FAILED) causes pipelines
to re-run the current step until the gate clears.