Every Voyager custom report starts life as a plain-text script — a flat sequence of //Section ... //End Section blocks. For the columnar grid style (the default when //Database is empty, and the encouraged style for on-screen grids and exports), two sections do almost all the work: //Columns, which defines what the grid looks like, and //Filter, which defines what the user gets to prompt for. Get comfortable with those two and you can write most rent-roll-shaped reports from scratch.
This walks through both, field by field, using a minimal skeleton — a property/unit list with three columns and a property filter.
The shape of the file
Section markers are case-insensitive and whitespace-tolerant (//End Select, //end select, //END SELECT all parse the same way), and the conventional order is //Notes → //Database///Options → //Title → //Version → //Select → //ColHeading → //Columns → //Filter — though order isn't strictly enforced, just conventional. The //Select section holds your actual T-SQL query. //Columns and //Filter, by contrast, aren't SQL at all — they're comma-delimited spec tables, one row per column or per prompt.
Here's a minimal //Select to build the rest of the example against:
//Select
select
p.scode PropertyCode
,u.scode UnitCode
,u.dsqft Area
from property p
inner join unit u on u.hproperty = p.hmy
where 1=1
and u.exclude <> -1
#conditions#
order by p.scode, u.scode
//End Select
Three output columns — property code, unit code, area — and a #conditions# token sitting in the WHERE clause. That token is the bridge to //Filter, and we'll come back to it.
//Columns: one row per output column
Each row in //Columns binds, positionally, to one column coming out of the //Select. The commonly-used fields, in order, are: Type, Name, Head1, Head2, Head3, Head4, Show, Color, Formula, Drill, Key, Width, Total (the full grammar has a few more rarely-used slots after Total — Control, Mandatory, List, Validate, Default, Keep, Suppress — that exist but are rarely populated; do computation in the SQL instead of leaning on them).
For the property/unit example:
//Columns
//Type, Name, Head1, Head2, Head3, Head4, Show, Color, Formula, Drill, Key, Width, Total
T, , , , , Property, Y, , , 3, , 700, N
T, , , , , Unit, Y, , , 4, , 700, N
D, , , , , Area, Y, , , , , 900, Y
A few things worth understanding about each field:
- Type is a one-letter code:
Tfor left-aligned text,Dfor decimal/money (locale-formatted, totalable),Ifor integer,Afor a locale-formatted date,Nfor plain numeric,Mfor month/period,Bfor a band/group-break column that the grid groups and breaks on. Money should beDwithTotal=Y; dates should beAso the date mask applies. - Head1–Head4 are stacked header-line slots — which ones you fill in controls the vertical label placement over the column. In the example above, only the fourth slot (Head4) is used, giving each column a single-line header.
- Show is
Y/N.Nhides the column from the grid but keeps it available as a drill key — useful for anhMyid column your report needs internally but shouldn't display. - Drill turns a cell into a link that opens a Voyager entity screen. The integer is an entity code —
1for lease/tenant,3for property,4for unit,6for transaction — and the//Selecthas to emit the matching hidden id column (typicallyShow=N) for the engine to know what to navigate to. - Width is in twips; leave it blank for auto-width.
- Total set to
Ysums the column in group and report footers — this is why the Area row above hasTotal=Yand the two text columns don't.
Rows parse by comma position, so pad trailing commas even when a field is empty, and comment rows can start with //.
//Filter: one row per runtime prompt
//Filter defines what shows up as a prompt when the report runs. Each row is: Type, DataTyp, Name, Caption, Key, List, Val1, Val2, Mandatory, Multi, Title.
//Filter
//Type, DataTyp, Name, Caption, Key, List, Val1, Val2, Mand, Multi, Title
C, T, p.hMy, *Property, , 61, p.hMy = #p.hMy#, , Y, Y, Y,
Breaking that row down:
- Type is the widget kind:
Cfor an entity-picker combo bound to a numeric picklist,0for a free-form value box,Mfor multi-select,Lfor a single-select dropdown,Rfor a range (which pairs two token stems,from:to),Tfor typed text with a predicate. - DataTyp is the value type:
Ttext,Adate,Iinteger,Mmonth/period,Nnumeric. - Name is the token stem — this row's is
p.hMy, which is what makes#p.hMy#in the query below resolve. - Caption is the prompt label; a leading
*(as in*Propertyabove) marks it as the primary filter. - List points at the value source. It can be a numeric Voyager picklist id —
61is property (single-select, the most common one you'll use),1is tenant/lease,4is unit,12is charge code — an inlineSELECTreturning value/caption pairs, or a caret-delimited literal list like^Current^Past(a leading caret adds a blank option). - Val1 is the actual SQL fragment spliced into the query when the filter is supplied — here,
p.hMy = #p.hMy#. Multi-select filters pre-quote the token so it expands into a validINlist, e.g.col in ('#Name#'). - Mandatory and Multi are
Y/Nflags — this filter is required and allows multiple properties. - Title, set to
Y, echoes the chosen value back into the printed report title.
How the two sections connect back to //Select
The #conditions# token in the //Select block is the catch-all slot Voyager splices every active filter's Val1 fragment into at runtime; inactive filters simply contribute nothing, so the query stays valid no matter which combination of filters the user actually fills in. Numbered variants — #condition1#, #condition2#, and so on — exist for reports with multiple datasets that need to route different filters to different queries. Every filter's own Name also becomes a standalone token (#p.hMy# here) available anywhere in the SQL, not just inside the condition slot — which is exactly what made p.hMy = #p.hMy# in the Val1 fragment above resolve to something the database can run.
Putting the whole thing together
A complete minimal report is just these pieces plus //Notes (documentation, ignored by the engine, but worth keeping current) and a //Title:
//Notes
NAME: rs_<pack>_<ReportName>.txt
DESCRIPTION: <one line>
MODIFIED
<date> <ticket> <author> Created.
//End Notes
//Title
<Report Title>
//End Title
//Select
select
p.scode PropertyCode
,u.scode UnitCode
,u.dsqft Area
from property p
inner join unit u on u.hproperty = p.hmy
where 1=1
and u.exclude <> -1
#conditions#
order by p.scode, u.scode
//End Select
//Columns
//Type, Name, Head1, Head2, Head3, Head4, Show, Color, Formula, Drill, Key, Width, Total
T, , , , , Property, Y, , , 3, , 700, N
T, , , , , Unit, Y, , , 4, , 700, N
D, , , , , Area, Y, , , , , 900, Y
//End Columns
//Filter
//Type, DataTyp, Name, Caption, Key, List, Val1, Val2, Mand, Multi, Title
C, T, p.hMy, *Property, , 61, p.hMy = #p.hMy#, , Y, Y, Y,
//End Filter
Six sections, one property filter, three columns — and every other columnar report on a Voyager install is a variant of this same shape with more columns, more filter rows, and richer SQL in //Select.
Where to go from here
Once the basic shape is comfortable, the places people actually get stuck are knowing which real tables and columns belong in //Select for a given business question, and which of the less-common //Columns///Filter fields (drill codes, picklist ids, multi-select token expansion) apply. That's what the PropETL SQL Query Assistant is built to help with — it carries the full //Columns and //Filter grammars alongside a queryable map of the Voyager schema (9,793 tables, 137,000+ columns, 12,900+ relationships), plus sanitized script skeletons by category, queryable in plain English inside Claude.
It's included in every tier, including the free 7-day trial. Details on the SQL Assistant page.
