POE Assistant

Overlay POE — Lua API

everything you can read and do from a Lua script — the POEMEMORY reference

← back to POE Assistant ready-made example scripts →

Overview

what scripting is, and the one rule that shapes everything

A script is a small Lua program that runs against your live game data. It reads the game through one read-only object — POEMEMORY — and reacts: pop a toast, play a sound, draw a box on a monster, or (only if you opt in) press a key or click the mouse.

Scripts are plain .lua files in settings_overlay/scripting/, managed from the SCRIPTING tab in the dashboard (enable/disable, order, edit, live log). Drop a file in the folder or hit + new.

This is the Overlay's Lua, not the launcher's AutoHotkey. The assistant launcher has its own, separate scripting system, documented under In-depth Scripting and AI Agent Scripting on the main site. The two share nothing but the word — different language, different data, different abilities. Everything on this page belongs to Overlay POE.

Four things to know up front

Read-only
Scripts observe the game; there is no way to write to it. POEMEMORY has no setters.
Sandboxed
No files, no network, no OS access — a shared script can't touch your machine. Only the API on this page exists.
Per-tick
You write one function, tick(), and it runs on a loop (~20×/second by default; adjustable in the tab).
Input is opt-in
Keyboard/mouse output is OFF by default, only fires while the game is focused, is rate-limited, and has a panic key. See Input & Mouse.

Lua & the object model

a 2-minute primer — enough Lua to be productive

Lua is small and readable. Here's essentially everything you need:

-- comments start with two dashes
local x = 10                 -- a variable (use "local")
if x >= 5 and x ~= 7 then    -- and / or / not ; "not equal" is ~=
  log("x is " .. x)          -- ".." joins strings
end

for i = 1, 3 do end                       -- numeric loop
for _, m in ipairs(someList) do end        -- loop a list (array)

function greet(who)          -- a function
  return "hi " .. who
end

How you talk to POEMEMORY

POEMEMORY, Input and Mouse are objects. Reading a value (a property) uses a dot and no parentheses. Calling a function (a method) uses parentheses; methods on an object take a colon (a dot also works):

local lvl  = POEMEMORY.Player.Level                 -- property: just a value
local hp   = POEMEMORY.Player.Life.Pct              -- nested property
local near = POEMEMORY.World.Monsters:CountWithin(30)  -- method: parentheses (colon)

Rule of thumb: if it has () in this reference, it's a method — call it. If it doesn't, it's a value — read it.

The tick() loop & remembering things

Define function tick() ... end. It runs every tick. Anything you declare outside tick() persists across ticks — that's how you keep state (like a timestamp):

local last = 0                 -- lives across ticks

function tick()
  if now() - last < 1000 then return end   -- not yet a second? bail
  last = now()
  -- ... this block runs at most once per second
end
No sleepThere is deliberately no blocking sleep (it would freeze the engine), and the sandbox has no os.clock. To pace yourself, use now() (milliseconds) with the pattern above.

Nil — always check

Some methods return nothing (nil) when there's nothing to give — e.g. Nearest() when no monster is loaded. Guard it:

local m = POEMEMORY.World.Monsters:Nearest()
if m then                      -- only use m when it exists
  log(m.Metadata)
end

POEMEMORY.Player

you — vitals, level, position

.Name string
Your character name.
.Level number
Character level.
.Xp number
Total experience.
.X number   .Y number
Your position in grid units (useful for movement / idle detection between ticks).
.IsAlive bool
true while you have positive life.
.Life   .Mana   .Es   .Rage pool
Vital pools, each with the fields below. .Es is energy shield. .Rage works on both games even though they store it differently — see the note under the pool fields.
:HasBuff(id)   :Charges(id)   :BuffCount(id)   :Buffs()
What is currently on you, and how many stacks. Full list in Buffs & charges.
:Flask(slot)   :FlaskCharges(slot)   .FlaskCount   :Flasks()
Your belt, slot by slot. Full list in Flasks & charms.

Each pool (.Life / .Mana / .Es / .Rage)

.Current number
Current value.
.Max number
Usable (unreserved) maximum.
.Pct number
Current as a percentage of the usable max (0–100). The one you'll use most.
.Present bool
Whether the pool exists — chiefly meaningful for .Es and .Rage (true only when you actually have one).
Rage is stored differently on each game — you don't have to careOn PoE2 rage is a real pool, so all four fields work normally. On PoE1 there is no rage pool at all: rage is a buff with a stack count. .Rage.Current reads correctly on both, but on PoE1 .Max and .Pct are 0, because the game exposes no maximum there. So use .Current for rage, not .Pct.
if POEMEMORY.Player.Life.Pct < 50 then notify("low life!", "ff4040") end
if POEMEMORY.Player.Es.Present and POEMEMORY.Player.Es.Pct < 25 then notify("ES down") end

Buffs & charges

what's currently on you — auras, flasks, ailments, and charge stacks

Ids are the game's own stringsLower-case with underscores, exactly as the game names them: power_charge, frenzy_charge, endurance_charge, ghost_dance_stacks, mana_leech… Matching is case-insensitive, so don't lose time to capitalisation. Not sure what a buff is called? Print them — see the bottom of this card.

Quick checks — on POEMEMORY.Player

:HasBuff(id) bool
true while that buff is on you.
:Charges(id) number
How many stacks / charges — e.g. how many power charges you're holding. 0 when the buff isn't there, so you can compare it without checking first.
:BuffCount(id) number
How many separate entries of that buff you have. Different question from :Charges — see the note below; pick the wrong one and your number is quietly wrong.
:BuffTimeLeft(id) number
Seconds left. 0 when absent. A permanent buff (an aura, a reservation) returns math.huge — infinity.
:BuffPercent(id) number
Time left as a percent of that application's full length (0–100). Returns 100 for permanent buffs and when the game stores no total — which is the case for charges. So a reading of 100 can mean “no percentage exists here”; when in doubt use :BuffTimeLeft.
:Buff(id) buff | nil
The whole thing as a handle (fields below), or nil when it isn't on you.
:Buffs() list of buffs
Everything on you right now — loop it with ipairs.

The buff handle

.Id string
The game's id, e.g. power_charge.
.Charges number
Stack count. 1 for a plain buff that doesn't stack.
.TimeLeft number
Seconds remaining; math.huge when permanent.
.TotalTime number
How long this application lasts in full. Watch out: frenzy and endurance charges report math.huge here even while they are visibly counting down — the game stores no fixed total for them. Never judge “permanent” from this field.
.PercentTimeLeft number
0–100.
.IsPermanent bool
Never expires on its own. Check this instead of comparing against infinity. It looks at .TimeLeft only — an infinite .TotalTime does not make a buff permanent.
.HasKnownDuration bool
True only when .PercentTimeLeft actually means something — a real countdown and a real total. false for permanent buffs and for charges. When it is false, read .TimeLeft.
.SourceEntityId number   .FlaskSlot number   .Effectiveness number
Who applied it, which flask slot it came from, and its effect strength as a real percentage. PoE2 only — on PoE1 these read 0.
⚠ Lots of time left does NOT mean safeA buff can leave for three different reasons, and only one of them is the clock:
it ran outTimeLeft reached 0;
it was spent — a charge consumed by a skill. Power charges last 21 seconds, but they vanish the instant you use them, with 19 seconds still showing;
it was switched off — a permanent aura or reservation you toggled.
So gate on :Charges(...) or :HasBuff(...) every tick. Never assume a big TimeLeft means it's still there next tick.
Some buffs appear more than onceThat's normal and it isn't a stack count. Leech, recoup and reservation effects genuinely repeat — one entry per application. A charge buff is the opposite: always one entry carrying a count. So for a charge read :Charges(id), and for the repeating kind read :BuffCount(id), which counts entries. Live example: on PoE1 life_leech showed up as 15 separate entries, each with a count of 1.

Finding the id you want

Run this once with the buff active, read the log, then copy the id into your real script.

-- print everything on me, once per second
local last = 0
function tick()
  if now() - last < 1000 then return end
  last = now()
  for _, b in ipairs(POEMEMORY.Player:Buffs()) do
    log(b.Id .. "  x" .. b.Charges .. "  " .. (b.IsPermanent and "permanent" or string.format("%.1fs", b.TimeLeft)))
  end
  log("----")
end
-- the everyday uses
if POEMEMORY.Player:Charges("power_charge") >= 3 then notify("3 power charges!", "c792ea") end
if not POEMEMORY.Player:HasBuff("ghost_dance_stacks") then notify("ghost dance down") end

local t = POEMEMORY.Player:BuffTimeLeft("flask_effect_mana")
if t > 0 and t < 1.5 then notify("mana flask expiring") end

Flasks & charms

what is in each belt slot, and how many charges it holds

Slot 1 is the leftmost belt cellThe same number you press in game. Slots are counted from 1, not 0. An empty slot returns nil from :Flask() and 0 from :FlaskCharges(), so you can test either way.
On PoE2 the belt also holds charmsPoE2 keeps flasks and charms in the same container, so a five-item belt is often 2 flasks + 3 charms and :Flask(3) may well be a charm. Read .Name or .BaseId if the difference matters to your script. PoE1 has no charms, so there it is flasks only.

On POEMEMORY.Player

:Flask(slot) flask | nil
The item in that belt slot as a handle (fields below), or nil when the slot is empty.
:FlaskCharges(slot) number
Charges in that slot right now. 0 when empty, so you can compare it without checking first. This is the live number — the one the game itself shows you.
.FlaskCount number
How many belt slots currently hold an item.
:Flasks() list of flasks
The whole belt in slot order — loop it with ipairs.

The flask handle

.Slot number
Belt slot, 1 = leftmost.
.Name string
The readable base name, e.g. Hallowed Life Flask or Silver Charm.
.BaseId string
The internal base id, e.g. FlaskUtilitySilver (PoE1) or FourCharm7 (PoE2). It does not change with your game language, so match on this if your script has to work anywhere.
.Charges number
Charges right now. The only live, mod-aware number on this handle — see the warning below.
.BaseMax number
Maximum charges of the base type. Your own flask can hold more.
.BaseUseCost number
What one use costs on the base type. Your own flask can cost less.
.Uses number
Uses left at the base cost. A floor, never a ceiling.
.CanUse bool
Enough charges at the base cost. Errs on the safe side — see below.
.Pct number
Charges as a percent of .BaseMax. Can read above 100 — that is correct, not a bug.
⚠ Only .Charges knows about your mods.BaseMax and .BaseUseCost come from the game's shared base-type data, which every flask of that type reads from. Your flask's own modifiers are not in there.
So a flask with "+X to maximum charges" really holds more than .BaseMax says — measured up to 3x the base value — and a flask with "-X% charges used" really costs less than .BaseUseCost says.
Two consequences: .Pct can go over 100, and .Uses/.CanUse can say "not yet" when the flask is in fact ready. They are wrong in the safe direction only. For an exact test, compare .Charges against a number you measured on your own flask.
Don't hold on to a flask handleRead it fresh each tick. Using a flask destroys and rebuilds it inside the game, so a handle you stored last tick can quietly start describing a different flask. The overlay re-reads the whole belt every tick for exactly this reason — just don't cache the result yourself.

Seeing your belt

Easiest way: open the dashboard's SCRIPTING tab and press show character current values — the popup lists your belt slot by slot, with names and charges. Or print it:

-- print the whole belt, once per second
local last = 0
function tick()
  if now() - last < 1000 then return end
  last = now()
  for _, f in ipairs(POEMEMORY.Player:Flasks()) do
    log("[" .. f.Slot .. "] " .. f.Name .. "  " .. f.Charges .. "/" .. f.BaseMax)
  end
  log("----")
end
-- the everyday uses
if POEMEMORY.Player:FlaskCharges(1) < 10 then notify("life flask nearly empty", "ff5555") end

local f = POEMEMORY.Player:Flask(2)
if f and f.CanUse then notify(f.Name .. " ready", "7ee787") end

-- find a flask by name instead of by slot
for _, x in ipairs(POEMEMORY.Player:Flasks()) do
  if x.Name:find("Quicksilver") and x.Charges == 0 then notify("quicksilver empty") end
end

POEMEMORY.Area

where you are right now

.Name string
The zone's display name (may be blank in some cases — prefer .Code to match on).
.Code string
The internal area code (e.g. a map/hideout identifier). The reliable thing to test against.
.Level number
Area/monster level.
.Hash number
Unique per zone instance — changes every time you enter a new instance.
.IsMap bool
true in a farmable combat zone (a map).
.InTown bool
true in a town/hideout. Handy to disable automation while you're safe.
.ChangeCount number
Increments on every zone transition — detect "just changed zone" by watching it.
if POEMEMORY.Area.InTown then return end   -- do nothing in town
if POEMEMORY.Area.Code:find("Cavern") then notify("cavern map!") end

POEMEMORY.World.Monsters

the enemies loaded around you

ScopeThese only see hostile, living monsters inside the loaded area around you (roughly what the radar draws) — not the whole map. Distances are in grid units (the same unit as the overlay's alert ranges).

Counts (return a number)

:Count() number
All loaded hostile-living monsters.
:CountWithin(range) number
How many are within range grid units of you.
:MagicCountWithin(range)   :RareCountWithin(range)   :UniqueCountWithin(range) number
Same, filtered to a rarity.
:CountMatching(text, range) number
Within range whose metadata contains text (case-insensitive substring).
:NearestDistance() number
Distance to the closest monster (a very large number if none).

Handles (return a monster, or a list)

:Nearest() handle | nil
The closest monster as a handle, or nil if none are loaded.
:Within(range) list of handles
Every monster within range — loop it with ipairs.
:Matching(text, range) list of handles
Every monster within range whose metadata contains text.
-- warn on a swarm
if POEMEMORY.World.Monsters:CountWithin(35) >= 8 then notify("swarm!", "ff5555") end

-- box every unique nearby
for _, m in ipairs(POEMEMORY.World.Monsters:Within(45)) do
  if m.IsUnique then m:Mark("a020f0") end
end

The monster handle

what :Nearest() / :Within() / :Matching() give you

A handle is a frozen snapshot of one monster for this tick. The same monster keeps the same .Id across ticks, so you can track it.

.Metadata string
Full metadata path (what you match rules against).
.Rarity string
"Normal" / "Magic" / "Rare" / "Unique".
.Id number
Stable per spawn — use it to track a specific monster tick to tick.
.Distance number
Grid units from you. The workhorse.
.ScreenX number   .ScreenY number
Where the monster is on screen, in overlay-window pixels — the exact coordinates Mouse takes. Only valid when .OnScreen.
.OnScreen bool
true when the monster projects on screen. Always check before using ScreenX/ScreenY.
.Life.Current   .Life.Max   .Life.Pct number
The monster's HP (percent 0–100).
.IsAlive bool
Alive (positive HP).
.IsUnique bool   .UniqueName string | nil
Whether it's a unique monster, and its name if so.
.IsFriendly bool
Your minions / allies (already excluded from the Monsters queries, but exposed here too).
:HasMod(text) bool
true if any of the monster's mod ids contains text (case-insensitive).
.Mods list of strings
All the monster's mod ids (rares/uniques). Advanced.
:Mark(color [, width, height, stroke])
Draw a hollow box on the monster's on-screen position, in color ("ffcc00"). It stays glued to the monster while you keep calling it. Size defaults to 70×70 with a 3px edge.
local m = POEMEMORY.World.Monsters:Nearest()
if m and m.Rarity == "Rare" and m.Distance < 25 then
  m:Mark("ff9030")                       -- highlight it
  log(m.Metadata .. "  " .. math.floor(m.Life.Pct) .. "%")
end

Actions — notify, sound, log, now

the safe outputs — no gate, available to every script

notify(text [, color])
Pop an on-screen toast. color is a hex string like "ff5555" (optional). Calling it again with the same text just keeps that toast up rather than stacking.
sound([variant])
Play a chime — variant 1–3 for the built-in sounds, 4 for your custom alert.wav. Uses your global alert volume, and is cooldown-limited so it can't machine-gun.
log(message)
Write a line to the log panel in the SCRIPTING tab. Your main debugging tool.
now() number
Monotonic milliseconds — the clock you throttle with (there's no sleep). See the once-per-second pattern.
notify("rare pack ahead", "ffcc00")
sound(2)
log("player at " .. POEMEMORY.Player.X .. ", " .. POEMEMORY.Player.Y)

Input & Mouse

the gated outputs — single, discrete keystrokes and clicks

⚠ Read this before using input Input is OFF by default. Turn on input enabled in the SCRIPTING tab. It only fires while the game window is focused, and it's rate-limited (about 7–8 actions per second — a single tick may send a short combo, then it's briefly blocked). Bind a panic key in the HOTKEYS tab first — it disables all script input instantly, even while alt-tabbed.

Input — keyboard

Input.Press(key)
Tap a key (press & release).
Input.Hold(key, ms)
Hold a key down, release after ms milliseconds (capped at 2000).
Input.Down(key)   Input.Up(key)
Press / release explicitly — for combos (hold Shift, click, release Shift).

Mouse

Mouse.Move(x, y)
Move the cursor to x, y — the same pixel space as a monster's .ScreenX/.ScreenY.
Mouse.Click([button])
Click at the current position. button = "left" (default) / "right" / "middle".
Mouse.ClickAt(x, y [, button])
Move to x, y then click. The one you'll use to attack a target.
Mouse.Down([button])   Mouse.Up([button])
Press / release a mouse button explicitly.
Mouse.Wheel(delta)
Scroll — 120 is one notch; sign is direction.
-- right-click the closest on-screen monster within melee range, once/sec
local MELEE = 20
local last = 0
function tick()
  if now() - last < 1000 then return end
  last = now()
  local m = POEMEMORY.World.Monsters:Nearest()
  if m and m.OnScreen and m.Distance <= MELEE then
    Mouse.ClickAt(m.ScreenX, m.ScreenY, "right")
  end
end

Key names & mouse buttons

what to pass to Input.* and Mouse.*

Keys

Single letters and digits are themselves: "q", "1". Named keys:

spaceenteresctabbackspacedeleteinsert homeendpageuppagedown updownleftright shiftctrlalt f1f12

Mouse buttons

leftrightmiddle

Gotchas & good habits

the things that trip people up

Grid units aren't obvious
There's no fixed pixel mapping. To find your melee/range number, temporarily notify("d "..math.floor(m.Distance)) and read it at the distance you care about.
Screen coords are ~1 frame old
Fine for click-to-target; not frame-perfect aim. Always gate on .OnScreen.
Loaded bubble only
World.Monsters sees what's loaded near you, not the far side of the map.
Keep tick() light
It runs constantly. A script that loops forever is caught by a watchdog and disabled after repeated errors — check the log.
Prefer .Code over .Name
Zone/monster display names can differ from what you match on; the code/metadata are the reliable strings.
A charge can vanish with time still on it
Charges are spent, not just expired. Re-check :Charges(id) every tick — a 21-second power charge can be gone one tick after you read 19 seconds left.
Permanent buffs report infinity
:BuffTimeLeft returns math.huge for auras and reservations. Use .IsPermanent rather than comparing numbers, or your "expiring soon" test will never fire.
An endless total does not mean an endless buff
Frenzy and endurance charges report .TotalTime as infinity while still counting down for real. Judge permanence from .TimeLeft or .IsPermanent — never from the total, or expiring charges will look permanent and sit at 100%.
A flask's max and cost are base values
Only .Charges knows about your flask's own mods. .BaseMax and .BaseUseCost come from shared base-type data, so .Pct can exceed 100 and .CanUse can say "no" on a flask that is actually ready. Both err on the safe side, never the risky one.