Skip to main content
The Pylon datasource surfaces a Pylon workspace as Forest collections. It exposes issues, accounts, contacts, users and teams on top of the Pylon API, with filters, free-text search, relations, the conversation thread of an issue, Pylon custom fields, CRUD writes and two action plugins.
The Pylon datasource is only available for Ruby (gem forest_admin_datasource_pylon). There is no Node.js equivalent yet.

Installation

Install the gem forest_admin_datasource_pylon.

Configuration

A single Bearer token is the whole configuration — an API key created from your Pylon workspace settings. Nothing validates it at boot: the custom-field introspection does call Pylon, but it is best-effort, so a 401 there costs the custom columns and a log line rather than the boot. A wrong key therefore starts the agent and fails on the first list. client.me is the check to run yourself — GET /me returns the organization owning the token, which is enough to prove the credentials are usable. api_key is mandatory; the datasource fails fast with a ForestAdminDatasourcePylon::ConfigurationError when it is missing or blank. Everything else is optional and only exists to trade throughput, timeouts and rate-limit behaviour.
The API key never reaches an inspect: Configuration, Client and Datasource all mask it, and the base URL is printed with its user-info redacted (an egress proxy spelled https://user:pass@proxy.internal would otherwise leak a password onto a Rails error page).
The forest_admin_datasource_pylon gem also ships two action plugins (CloseIssue and CreateIssueWithNotification) that you can attach to any host collection. See the Pylon plugins page for details.

Provided collections

Once the datasource is registered, five collections are added to your Forest project: PylonUser has no create and no delete (a Pylon agent is invited and deactivated from Pylon itself), and PylonTeam has no delete — Pylon exposes no such endpoints, and the datasource refuses those verbs rather than pretending to perform them. PylonIssue, PylonAccount and PylonContact also carry their Pylon custom fields, introspected at boot.

Relationships

The following relationships are exposed automatically:
  • PylonIssue.accountPylonAccount (foreign key account_id)
  • PylonIssue.requesterPylonContact (foreign key requester_id)
  • PylonIssue.assigneePylonUser (foreign key assignee_id)
  • PylonIssue.teamPylonTeam (foreign key team_id)
  • PylonAccount.issuesPylonIssue (origin key account_id)
  • PylonAccount.contactsPylonContact (origin key account_id)
  • PylonContact.accountPylonAccount (foreign key account_id)
  • PylonContact.requested_issuesPylonIssue (origin key requester_id)
  • PylonUser.assigned_issuesPylonIssue (origin key assignee_id)
  • PylonTeam.issuesPylonIssue (origin key team_id)
Every one of those keys is filtered server-side by /issues/search or /contacts/search, so a related list costs one request and no in-memory pass. Two relations are not exposed, for two different reasons:
  • Team membership, which Pylon’s shape does not allow. Pylon nests its members inside a team and exposes no team id on a user, so the membership is a many-to-many with no key column to build it on. PylonTeam.user_ids carries the member ids as a Json column instead.
  • Account owner, which it would allow. PylonAccount.owner_id points at a PylonUser and would be resolved like any other key, but is left as a plain column until something in the panel asks for the owner of an account.

Conversation thread

Pylon has no way to read the threads of several issues at once, so messages are not exposed as their own collection. Instead, each issue carries a structured messages array column, fetched from GET /issues/{id}/messages only when the projection asks for it (i.e. when messages is rendered on the detail view or referenced in a custom action). Each entry has the following shape: The column is read-only, and neither filterable nor sortable: POST /issues/search covers no message field, and the search payload does not even carry the thread. One thread is one request, and a thread is the whole conversation rather than a page of it, so a list view asking for more of them than MAX_MESSAGE_EMBEDS (10) reads the first ones and logs a warning. A row past that cap — and a row whose thread could not be read — is left at nil (“unknown”), never at an empty list, which would read as “this issue has no message”.

Custom fields

Custom fields defined in your Pylon workspace are introspected at boot, one GET /custom-fields call per object type (issue, account, contact — Pylon carries none on users and teams), and added to the matching collection’s schema under the Pylon slug verbatim: the slug is both what a read payload indexes the values by and what a search filter sends. The Forest column type is derived from the Pylon field type:
  • An unrecognized type is skipped and logged rather than guessed at.
  • A column name colliding with a native column is skipped.
  • A user field holds a Pylon user id and stays a String rather than becoming a relation.
  • A select advertises the slugs of its options (Pylon reads and filters a select by slug, never by label). A select whose options were all removed falls back to a read-only String, so the column still shows what it holds.
  • Nothing is sortable (no Pylon endpoint takes a sort parameter) and nothing is groupable.
  • A custom field is writable only when Pylon flags it is_read_only: false. A definition carrying no flag at all is left read-only and reported once, because nothing can tell it apart from a field synced from an app — whose every save Pylon would reject.
The introspection runs while the datasource is being constructed, in front of your Rails boot, which is why it uses its own short timeouts (boot_open_timeout, boot_timeout, boot_retry_policy).If it fails, it costs the custom columns and not the datasource: the agent boots on the native schema and says so in the log. The first failure also stands for the object types after it, rather than paying the same timeout three times over.

Capabilities

Pylon is a ticketing API, not a database, and several things Forest asks for have no equivalent. Where that happens the datasource refuses with a message naming the reason rather than answering something that looks right and is not. All of these reach the operator as a 400 carrying that text.

Filters

The condition tree is translated into the filter payload of the matching Pylon search endpoint. Each collection derives its columns’ filter operators from the allow-list of its endpoint, so the UI never offers a filter of the collection’s own that Pylon would refuse.
Every other column of PylonIssue carries no filter operator at all, POST /issues/search covering nothing else.
Notes that apply to every collection:
  • Custom fields are filtered through their slug, with the operators of the column the introspection built: the equality and presence families, plus substring on a String and the bare comparisons on a date. A Number gets no comparison (Pylon documents time_is_after / time_is_before and nothing else, so a numeric range would travel as a time filter), and a multiselect gets nothing.
  • Presence filters need a field that supports them. Forest derives PRESENT / BLANK / MISSING from an equality filter above the datasource and rewrites them into a comparison with an empty value. Only a field carrying the presence family can answer one — Pylon matches an absent value through is_set / is_unset alone — so on every other field the translator refuses the rewritten condition and names the filter to change, rather than sending a comparison Pylon would answer as if the empty value were a value of its own.
  • A condition on a relation is resolved by reading the foreign collection for its keys and sending them as an in. Past MAX_RELATION_KEYS (500) the condition is refused rather than truncated. A resolution matching no foreign record answers with no record at all.
  • NOT_EQUAL is offered wherever NOT_IN is, although no table above lists it: the datasource declares the one spelling Pylon takes, and the agent republishes the other from it. The same rewrite is what puts PRESENT / BLANK / MISSING in front of the fields that carry them.
  • In and NotIn are refused when empty rather than matched against everything.
  • Non-finite numbers are refused. A Number filter the agent cast to Infinity or NaN is answered with a 400 naming the field.

Primary-key lookups

POST /issues/search has no id filter, so a primary-key lookup on PylonIssue is short-circuited to GET /issues/{id}, one request per id. Any id leaf of a top-level AND is taken — Forest sends AND(id equal X, <scope>) on a record detail as soon as a scope or a segment is set — and two of them are intersected, an AND naming the records all of its conditions name. That fan-out is capped per page at MAX_ID_LOOKUPS (20): the window is taken off the ids first, so a wider selection is read a page at a time rather than truncated at its first twenty. The one shape that cannot be paged that way is a lookup carrying a residual condition — which records the page holds could only be known by reading all of them — and it is refused past the cap. Two further refusals on this path:
  • Combining free-text search with an id filter is refused: search and id lookup are different endpoints, and neither can do the other’s half.
  • A nested AND carrying an id is refused instead of resolved. It fails closed, and the agent flattens one level of grouping, so every shape the UI builds keeps its id at the top level.
PylonAccount and PylonContact need none of this: their search endpoints filter id server-side.

Sorting

No Pylon endpoint takes a sort parameter. On PylonIssue, PylonAccount and PylonContact no column is advertised as sortable, and a requested order is reported in the log rather than silently swallowed — issues always come back newest first, accounts and contacts in whatever order the API imposes. PylonUser and PylonTeam are the exception: their endpoint hands back the whole dataset, so every scalar column is sortable and the sort is applied in memory over all of it (Json columns are not).

Pagination

Forest’s offset/limit window is translated into Pylon’s cursor pagination: the client walks the cursor until it has collected the window the caller asked for, capped at MAX_PAGES (20) pages and MAX_RECORDS (5 000) records per walk, with a page size clamped to MAX_SEARCH_LIMIT (1 000). A walk cut short by those caps logs a truncation warning. A filter carrying no page asks for every record it matched, and travels as no limit at all rather than as a stand-in figure — so it cannot be mistaken for a window the caller asked for and truncated silently. For PylonUser and PylonTeam, the whole response is re-read on every list and the window is cut out of it in memory. GET /users is read with deactivated agents included, deliberately: a deactivated agent stays the assignee and the author of the issues they handled, and is_deactivated is exposed as a column so you can filter them out.

Aggregations

PylonIssue, PylonAccount and PylonContact cannot be aggregated at all. Pylon exposes no aggregate endpoint and no total, and counting or grouping the pages a cursor walk collected would answer a fraction of a collection as if it were the whole of it. Every column is registered non-groupable so the UI never offers a group-by, and a chart built through the API anyway is refused with a message saying why. Those collections are not advertised as countable either, so the record count is not displayed. PylonUser and PylonTeam are countable and groupable, and exactly so: their endpoint hands back every record Pylon holds, so a count or a group over it is the figure a server-side aggregation would have given. That claim is checked rather than assumed — the read follows a cursor if Pylon ever advertises one, and refuses outright rather than answer over a fraction of the collection should it ever paginate past MAX_COLLECTED_PAGES (10) pages. The free-text search bar is enabled on PylonIssue, PylonAccount and PylonContact: the search term travels to the matching POST /*/search endpoint alongside the filters, so the list is the one Pylon itself matched. PylonUser and PylonTeam are not searchable — their endpoint takes no search term — but every scalar column is filterable in memory instead.

Writes

Create, update and delete are supported as the collections table states. A write is one record per request, and everything below follows from that.
  • A write reaching more records than one pass covers is refused up front, at MAX_WRITE_REQUESTS (20) requests. On PylonIssue, where resolving the selection itself costs one GET /issues/{id} per record, the budget is divided accordingly — and never exceeds the primary-key page cap, so a resolution trimmed by paging cannot write to a subset of a selection while reporting the whole of it.
  • A write that fails halfway reports exactly which records were written, so a retry can target the untouched ones rather than performing the write twice (PartialWriteError).
  • A read-only column sent alongside a real edit is dropped and the edit performed. An edit naming only such columns is refused, naming them: it would write nothing, and the record the route reads back would show the operator their change reverting with no reason given.
  • A field Pylon only accepts in one direction is refused in the other, naming it. On PylonIssue, body_html and author_unverified are create-only (they are the first message of the thread, which PATCH /issues/{id} does not carry) while state and type are update-only (Pylon creates every issue as new, of the type it decides). On PylonContact, email is create-only and emails update-only. On PylonAccount, is_disabled is update-only — an account is created enabled.
  • Json columns holding objects are read-only even where the endpoint takes them, because the write shape is not the shape the column shows: PylonAccount.external_ids and channels / crm_settings, PylonContact.phone_numbers and external_ids. Writing one for the other would replace the data with something Pylon cannot read back.
  • Two projections of the same value are never both writable. PylonAccount.domain / primary_domain are read-only (domains is the list the API takes); PylonContact.portal_role and PylonUser.role_name are read-only, their ids being what is written.
  • A Pylon 4xx on a write travels to the operator with Pylon’s own message (WriteRejectedError). A 5xx or a dropped connection is not the operator’s to act on and stays an APIError.
A foreign key is forced read-only in the emitted schema whatever the datasource says, so the detail view shows one relation editor rather than two — but account_id, requester_id, assignee_id, team_id on PylonIssue and account_id on PylonContact are writable, which is exactly what opens that editor.

Rate limits and retries

Pylon meters per endpoint, not per token, from 30 to 300 requests a minute depending on the endpoint. The datasource ships the documented budget of every endpoint it calls and spaces requests out so each one is spent rather than exceeded — a sliding window per endpoint, in front of the 429 retry rather than instead of it. An endpoint absent from the table falls back to the lowest figure documented anywhere on the API (30/min), bucketed by its first path segment. The limiter is a smoother, not a guarantee: past DEFAULT_MAX_WAIT (5s) a request goes out anyway and the 429 retry takes over, with one log line per endpoint per window saying so. Under real saturation — several agents or processes on the same token — the retry is the defence. The retry is bounded, deliberately. A 429 carries a Retry-After of up to a full minute, and waiting one out on every attempt held the calling thread for minutes on a request the Forest server had already timed out. RetryPolicy::DEFAULT_MAX_INTERVAL (12s) caps what one attempt waits; past it the 429 surfaces as an error instead. So a saturated endpoint answers the operator with a message rather than with a page that arrives long after they gave up. Raise it — or lower max_retries — to trade the other way:
To meter on your own side instead, take the limiter out of the stack:
Retries apply to 429, 502, 503 and 504, plus timeouts and dropped connections. Only GET, HEAD and OPTIONS are replayed on a transport failure — a dropped connection on the way back from a DELETE Pylon did perform would otherwise be replayed into a 404 and reported as a deletion that failed when it landed. A 429 is retried on any verb, Pylon having rejected the request before processing it.

Errors

The four 400 classes descend from the toolkit’s ValidationError, so the agent answers with their message intact: each one names something the operator did and can undo, and the message is the only place they learn which condition to change.

Logging

The datasource uses Rails.logger when available, and falls back to Logger.new($stderr). You can override it explicitly:
Best-effort paths log a warning and degrade rather than failing the whole page render: custom-field introspection, a conversation thread that could not be read, a truncated cursor walk, an order no endpoint honours, a saturated rate-limit window, and the issue-id writeback of the CreateIssueWithNotification plugin.

Source code

This connector is open source. Browse the code or contribute on GitHub: forest_admin_datasource_pylon