Control4 Agent Reference

# Recently Played Manager

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

_Diagram: Sequence diagram: a driver posts SetHistoryItem to the agent; if artwork or titles are missing the agent calls GET_CONTAINER_INFO back on the driver and the driver re-posts with keyToUpdate; the agent then notifies Navigator with historyUpdated. Later Navigator reads history and calls SelectHistoryItem, and the agent sends PLAY_RECENT to the driver._

_ 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.

```lua
RECENTLY_PLAYED_AGENT = next(C4:GetDevicesByC4iName("recentlyplayed-agent.c4z"))
```

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.

```lua
-- 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.

**Dispatch**

| Command | Handler | On |
| --- | --- | --- |
| SetHistoryItem SelectHistoryItem RemoveHistoryItem GetHistoryItemsBy… ClearHistory | ExecuteCommand | The agent. Reached with `C4:SendToDevice` from any driver. |
| SelectHistoryItem RemoveHistoryItem GetHistoryItemsBy… | UIRequest | The agent. Used by Navigator's own Listen screen. |
| PLAY_RECENT GET_CONTAINER_INFO | ReceivedFromProxy | Your driver, on its media service proxy binding. |

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:

```lua
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.

SetHistoryItem Write

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. |

```lua
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.

SelectHistoryItem Play

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`.

GetHistoryItemsByRooms Read

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.

GetHistoryItemsByDriver Read

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. | RemoveHistoryItem Write

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.

ClearHistory Maintenance

Empties all four tables. Runs regardless of the enabled state, and is also invoked automatically when **Agent Enabled** is switched to `Off`.

PrintHistoryByRoom Maintenance

Prints every room's history to the Lua output window as `title (driver name)`. Diagnostic only.

PrintHistoryByDriver Maintenance

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_RECENT Inbound

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`. |

```lua
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_INFO Inbound

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.

```lua
<historyUpdated>
  <key>a1b2c3…</key>
  <rooms>101,102</rooms>
  <driverId>42</driverId>
</historyUpdated>
```

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.

```lua
{
  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.

```lua
-- 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.

Recently Played Manager · Agent API reference Requires Control4 OS 3.3.1 or later
