Installation
Install the gemforest_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 a401 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).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.account→PylonAccount(foreign keyaccount_id)PylonIssue.requester→PylonContact(foreign keyrequester_id)PylonIssue.assignee→PylonUser(foreign keyassignee_id)PylonIssue.team→PylonTeam(foreign keyteam_id)PylonAccount.issues→PylonIssue(origin keyaccount_id)PylonAccount.contacts→PylonContact(origin keyaccount_id)PylonContact.account→PylonAccount(foreign keyaccount_id)PylonContact.requested_issues→PylonIssue(origin keyrequester_id)PylonUser.assigned_issues→PylonIssue(origin keyassignee_id)PylonTeam.issues→PylonIssue(origin keyteam_id)
/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_idscarries the member ids as aJsoncolumn instead. - Account owner, which it would allow.
PylonAccount.owner_idpoints at aPylonUserand 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 structuredmessages 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, oneGET /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
userfield holds a Pylon user id and stays aStringrather than becoming a relation. - A
selectadvertises 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-onlyString, 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 a400 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.- PylonIssue
- PylonAccount
- PylonContact
- PylonUser / PylonTeam
Every other column of
PylonIssue carries no filter operator at all, POST /issues/search covering nothing else.- 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
Stringand the bare comparisons on a date. ANumbergets no comparison (Pylon documentstime_is_after/time_is_beforeand nothing else, so a numeric range would travel as a time filter), and amultiselectgets nothing. - Presence filters need a field that supports them. Forest derives
PRESENT/BLANK/MISSINGfrom 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 throughis_set/is_unsetalone — 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. PastMAX_RELATION_KEYS(500) the condition is refused rather than truncated. A resolution matching no foreign record answers with no record at all. NOT_EQUALis offered whereverNOT_INis, 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 putsPRESENT/BLANK/MISSINGin front of the fields that carry them.InandNotInare refused when empty rather than matched against everything.- Non-finite numbers are refused. A
Numberfilter the agent cast toInfinityorNaNis 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
idfilter is refused: search and id lookup are different endpoints, and neither can do the other’s half. - A nested
ANDcarrying anidis refused instead of resolved. It fails closed, and the agent flattens one level of grouping, so every shape the UI builds keeps itsidat 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. OnPylonIssue, 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 atMAX_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.
Search
The free-text search bar is enabled onPylonIssue, 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. OnPylonIssue, where resolving the selection itself costs oneGET /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_htmlandauthor_unverifiedare create-only (they are the first message of the thread, whichPATCH /issues/{id}does not carry) whilestateandtypeare update-only (Pylon creates every issue asnew, of the type it decides). OnPylonContact,emailis create-only andemailsupdate-only. OnPylonAccount,is_disabledis update-only — an account is created enabled. Jsoncolumns holding objects are read-only even where the endpoint takes them, because the write shape is not the shape the column shows:PylonAccount.external_idsandchannels/crm_settings,PylonContact.phone_numbersandexternal_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_domainare read-only (domainsis the list the API takes);PylonContact.portal_roleandPylonUser.role_nameare 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 anAPIError.
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: pastDEFAULT_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:
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 usesRails.logger when available, and falls back to Logger.new($stderr). You can override it explicitly:
CreateIssueWithNotification plugin.
Source code
This connector is open source. Browse the code or contribute on GitHub:forest_admin_datasource_pylon