A project-wide agent that records what each streaming driver played, in which room, and
hands that history back to Navigator so a listener can resume it in one tap. This is the
command surface a driver talks to.
File
recentlyplayed-agent.c4z
Proxy
recentlyplayed-agent
Control
lua_gen
Minimum OS
3.3.1
The model
The agent stores containers: an album, a playlist, a station, a track. It does
not store individual songs as they roll past. One entry is written when playback of a container
starts, and it is updated in place if the same container plays again.
Every entry gets a random 20-character key and is filed into three indexes:
by key, by room, and by driver. A fourth table holds the timestamp used for ordering.
Reads are always scoped. A Navigator asks for a room's history or a driver's history,
never the whole store.
Each index is capped at 20 entries. Writes are persisted on a five-second
debounce, so a burst of updates costs one flush.
Storage
Table
Keyed by
Holds
InfoByKey
key
The container record, its driver, and the driver icon.
KeyByRoom
room id
Every key played in that room, capped at 20.
KeyByDriver
device id
Every key written by that driver, capped at 20.
TimestampByKey
key
os.time() of the last write. Sorts newest first.
Call flow
The agent is not a passive store: ochre arrows are agent-initiated and land on the
driver's ReceivedFromProxy. A driver that implements only the write path
will record history that nothing can replay.
Conventions
Finding the agent
The agent is installed automatically and there is exactly one per project. Resolve its
device id by filename, and re-resolve it when rooms are registered rather than caching it
across a driver restart.
If the lookup returns nil, skip the integration silently. Every call below is
an ordinary C4:SendToDevice to that id.
Payload encoding
Structured arguments cross the boundary as base64-encoded JSON. Anything the agent returns
arrives wrapped in a <b64json> element that must be unwrapped before decoding.
-- writing
local key = C4:SendToDevice(RECENTLY_PLAYED_AGENT, "SetHistoryItem", {
itemInfo = Serialize(itemInfo),
})
-- reading
local ret = C4:SendToDevice(RECENTLY_PLAYED_AGENT, "GetHistoryItemsByRooms", {
rooms = roomId,
})
local inner = string.match(ret, "<b64json>(.+)</b64json>")
local items = Deserialize(inner)
Room arguments
Anywhere a rooms value is accepted it may be a number, a comma-separated
string, or a table of either (nested tables are flattened). A call that resolves to zero
rooms is rejected.
Entry points
Which DriverWorks handler a command lands in depends on what is being addressed. The agent
has no proxy binding of its own, so commands sent to it arrive as ordinary driver commands;
your service driver does have a proxy, so commands the agent sends back arrive on that
binding instead.
The four read and replay commands are deliberately exposed on both of the agent's entry
points, and the UI handlers forward straight through, so there is one behavior to reason
about rather than two. The three maintenance commands are reachable as Composer actions,
which arrive as an ExecuteCommand named LUA_ACTION carrying the
real name in tParams.ACTION.
Nothing here is declared as a driver command in the agent's own manifest, so
none of it appears in Composer programming. The three actions are the
entire Composer-visible surface; everything else is code-only.
Library conventions are not the contract
The examples in this document are written against the shared driver library, where
EC., UIR. and RFP. are dispatch tables that a
generated ExecuteCommand, UIRequest and
ReceivedFromProxy route into. That layer is a convenience, not part of the
agent's interface. A driver that does not use it writes the plain handler and branches on
the command name:
function ReceivedFromProxy(idBinding, strCommand, tParams)
if strCommand == "PLAY_RECENT" then
-- …
elseif strCommand == "GET_CONTAINER_INFO" then
-- …
end
end
The same applies to the helpers. Serialize and Deserialize are
base64 over JSON and nothing more: C4:Base64Encode(JSON:encode(t)) and its
inverse. GetContainerInfo is not an agent concept at all: it is a stub the
media service player library ships for drivers to override, called from that library's own
GET_CONTAINER_INFO branch. Without the library there is no such function, only
the command.
What is genuinely fixed is the wire contract: the command names, the parameter names, and
base64-encoded JSON for itemInfo and info.
Driver → agent
Commands a driver sends to the agent's device id. Every one of these is a no-op returning
nil while Agent Enabled is Off, except the three
maintenance commands at the end.
SetHistoryItemWrite
Records or updates one container. Returns the entry's key as a string.
Hold on to it and pass it back as keyToUpdate for subsequent writes of the
same playback session.
Parameters
Name
Type
Notes
itemInfo
b64 JSON req
A serialized item payload. Shape below.
itemInfo = {
driverId = PROXY_ID, -- required, the calling driver's PROXY device id
rooms = "101,102", -- required, at least one room must resolve
keyToUpdate = nil, -- set to update an existing entry in place
info = {
driverId = PROXY_ID,
container = {
id = "12345", -- required, coerced to a string
itemType = "album", -- required, driver's own vocabulary
title = "Kind of Blue",
subtitle = "Miles Davis",
image = "https://…/640x640.jpg",
},
},
}
Returns early with no key when keyToUpdate is absent and
the container carries none of title, subtitle or
image. In that case the agent issues GET_CONTAINER_INFO back at
the driver and waits for a second call.
Deduplication. Without a keyToUpdate, the agent looks for an
existing entry from the same driver with a matching itemType and
id that is also present in the first room of the request. A match is
reused rather than duplicated, and its timestamp moves to the front.
Side effects. Fills in driverIcon from the driver's own
package, defaults a missing container.image to that same icon, stamps the
timestamp, trims both indexes, emits historyUpdated, and schedules a
persist.
Use the proxy device id for driverId, not
C4:GetDeviceID(). It is both the address the agent will later send
PLAY_RECENT to and the id it resolves the icon path from, so a driver that
reports its own device id instead of its proxy will store entries that never replay.
SelectHistoryItemPlay
Replays a stored entry. The agent looks up the key's owning driver and sends it
PLAY_RECENT.
Parameters
Name
Type
Notes
key
string req
Entry key. Must be a string.
rooms
rooms req
Target rooms for playback.
Returns <b64json> wrapping { success = true }, or an
{ error = … } object reading No room specified,
Invalid or missing key, or Key not found.
GetHistoryItemsByRoomsRead
Returns the merged history for one or more rooms, newest first.
Parameters
Name
Type
Notes
rooms
rooms req
One or more room ids.
limit
number
Defaults to 20.
A room is skipped unless Control4 Digital Audio is one of its listen devices.
A room with history but no digital audio path returns nothing. This is the single most
common reason a populated store looks empty.
GetHistoryItemsByDriverRead
Returns one driver's history, newest first, with no room filtering and no digital audio
check.
Parameters
Name
Type
Notes
driverId
number req
Device id of the driver. Defaults to 0, which matches nothing.
limit
number
Defaults to 20.
RemoveHistoryItemWrite
Deletes one entry from every index, emits historyUpdated carrying the rooms
and driver that were affected, and schedules a persist. Takes a single
key. Removing a key that does not exist is harmless.
ClearHistoryMaintenance
Empties all four tables. Runs regardless of the enabled state, and is also invoked
automatically when Agent Enabled is switched to Off.
PrintHistoryByRoomMaintenance
Prints every room's history to the Lua output window as
title (driver name). Diagnostic only.
PrintHistoryByDriverMaintenance
Prints every driver's history to the Lua output window, grouped by driver name.
Diagnostic only.
Agent → driver
Two commands the agent sends back. They arrive on the driver's proxy binding and must be
handled in ReceivedFromProxy. Neither is optional for a driver that wants
working history.
PLAY_RECENTInbound
Resume this container in these rooms. The driver owns the decision about how to do it
(queue replace, shuffle, station tune). The agent only supplies the identifier it
was given.
Parameters
Name
Type
Notes
rooms
string
Comma-separated room ids.
info
b64 JSON
The stored record, including container.id and container.itemType.
function RFP.PLAY_RECENT(idBinding, strCommand, tParams, args)
local info = Deserialize(tParams.info)
local container = info.container or {}
if container.id and container.itemType and tParams.rooms then
PlayItem(container.itemType, container.id, tParams.rooms)
end
end
The itemType here is the driver's original vocabulary, not the display
string shown in Navigator.
GET_CONTAINER_INFOInbound
A request for the metadata the driver did not supply. Look up the container, then call
SetHistoryItem a second time with keyToUpdate set to the key
handed back here. Until that second call lands, nothing is stored.
Parameters
Name
Type
Notes
keyToUpdate
string
Echo this back unchanged.
containerId
string
The id originally submitted.
containerType
string
The itemType originally submitted.
rooms
string
Pass through to the second write.
The lookup is normally an asynchronous HTTP call, so the handler returns immediately and
the second write happens in the response callback. A driver that always sends complete
metadata on the first write never sees this command.
Navigator surface
Navigator's built-in Listen screen is the primary consumer, and it reaches the agent
through the UI request path rather than by sending driver commands. It reads a room's
history when the screen opens, replays an entry on tap, and removes one on swipe. These are the
same four operations documented above, under the same names and with the same parameters
and return values.
historyUpdated
After any write or removal the agent pushes an event to attached user interfaces so a list
on screen can refresh without polling.
On removal, rooms lists only the rooms the entry was actually pulled from,
and driverId is omitted entirely if no driver index held the key. Parse these
elements by name. The order they are emitted in is not stable between events.
Data shapes
History item
One element of the array returned by either read command.
{
key = "kQ7m…", -- 20 characters
timestamp = 1731024000, -- os.time() of the last write
driverId = 42,
roomIds = "101,102", -- every room this entry appears in
info = {
driverId = 42,
driverIcon = "controller://driver/<driver>/icons/device/experience_512.png",
container = {
id = "12345",
itemType = "Album", -- display string, capitalized
itemTypeForDriver= "album", -- original value, use this to replay
title = "Kind of Blue",
subtitle = "Miles Davis",
image = "https://…/640x640.jpg",
},
},
}
Results are sorted newest first and deduplicated on a hash of
id and itemType, so the same album surfacing from two rooms
appears once. The limit is applied after deduplication.
Item type display names
The agent capitalizes the first letter of itemType for display and carries a
small internal table of overrides, keyed on driver filename, for the handful of cases where
a raw value would read badly in a list, such as an all-caps constant or a fully qualified
metadata class name. That table is not extensible from outside the agent, so choosing an
itemType that already reads as a noun is the only way to control the label.
Composer surface
Properties
Property
Type
Behavior
Agent Version
string, read-only
Driver version. Carries the disable reason if the OS is below the minimum.
Agent Enabled
On / Off
Defaults to On. Switching to Off clears the entire history.
History Size
string, read-only
Count of stored entries. Refreshed when the store persists, not on every write.
Debug Mode
On / Off
Reverts itself to Off after ten hours.
Actions and variables
Name
Kind
Notes
Clear History
action
Runs ClearHistory.
Print History By Room
action
Dumps the store grouped by room.
Print History By Driver
action
Dumps the store grouped by driver.
AgentEnabled
variable, bool
Mirrors the enabled property for programming.
Behavior notes
Rooms without digital audio read as empty
GetHistoryItemsByRooms filters out any room that does not list Control4
Digital Audio among its listen devices. Writes are never filtered, so a room can hold
history that it will never return.
Disabling clears, it does not pause
Setting Agent Enabled to Off wipes the store. Turning it
back on starts from nothing; there is no suspended state to return to.
Removing a device purges its history
The agent watches for item removal and drops every entry belonging to a deleted driver
or room. Uninstalling a service driver takes its history with it.
The write is the container, not the track
Per-track updates are deliberately not recorded. History shows what a listener chose
to play, so replaying an entry restarts the album or station rather than resuming one
song.
Cap enforcement is index-local
Each index keeps its newest 20 keys. An entry is deleted outright once it has aged out
of both its room index and its driver index during the same write, so a record can
briefly outlive its position in one list.
Icons are resolved from the calling driver
The agent derives driverIcon from the caller's own package path, so a
driver only needs to ship icons/device/experience_512.png to get correct
branding in the list.
Casting protocols cannot participate
Where playback is initiated outside Control4, such as casting protocols where a phone
app picks the content and the driver only carries the stream, the driver never
learns which container was chosen and has nothing to write. The same is true of any
non-streaming source: disc players, turntables, line-in devices.
Integration recipe
The minimum a service driver needs. Resolve the agent when rooms are registered, write on
playback, and handle the two inbound commands.
-- 1. resolve, when rooms are registered
RECENTLY_PLAYED_AGENT = next(C4:GetDevicesByC4iName("recentlyplayed-agent.c4z"))
-- 2. write, when a container starts playing
local function RecordPlayback(container, rooms)
if not RECENTLY_PLAYED_AGENT then
return
end
local itemInfo = {
driverId = PROXY_ID,
rooms = table.concat(rooms, ","),
keyToUpdate = CurrentQueue.RecentlyPlayedKey,
info = {
driverId = PROXY_ID,
container = {
id = container.id,
itemType = container.itemType,
title = container.title,
subtitle = container.subtitle,
image = container.image,
},
},
}
local key = C4:SendToDevice(RECENTLY_PLAYED_AGENT, "SetHistoryItem", {
itemInfo = Serialize(itemInfo),
})
if key then
CurrentQueue.RecentlyPlayedKey = key
end
end
-- 3. replay
function RFP.PLAY_RECENT(idBinding, strCommand, tParams, args)
local info = Deserialize(tParams.info)
local container = info.container or {}
if container.id and container.itemType and tParams.rooms then
PlayItem(container.itemType, container.id, tParams.rooms)
end
end
-- 4. backfill metadata, only needed if step 2 can omit title and image
function GetContainerInfo(containerId, containerType, keyToUpdate, rooms)
if not (containerId and containerType and keyToUpdate) then
return
end
LookUpContainer(containerId, containerType, function(data)
local itemInfo = {
keyToUpdate = keyToUpdate,
driverId = PROXY_ID,
rooms = rooms,
info = {
driverId = PROXY_ID,
container = {
id = containerId,
itemType = containerType,
title = data.title,
subtitle = data.artist,
image = data.image,
},
},
}
C4:SendToDevice(RECENTLY_PLAYED_AGENT, "SetHistoryItem", {
itemInfo = Serialize(itemInfo),
})
end)
end
Drivers built on the standard media service player library get steps 1 and 2 for free:
the library resolves the agent when it registers rooms, and writes an entry whenever a
queue is built with container information attached. Steps 3 and 4 stay the driver's
responsibility, since only the driver knows how to turn an id back into playback.