| name | worker-interview-research |
|---|---|
| description | Workflow for producing a research briefing on a Traba worker ahead of a Worker Interview Program call. Use when: (1) user asks for "deep research" / a "briefing" / "writeup" on a named worker, (2) user references the Worker Interview Program or asks who to prep for an interview, (3) user wants to understand a worker's experience on the app before a call. Covers: which prod tables to hit (and the gotchas), how to frame the worker as an archetype, how to scope interview angles to what's worth a 15-minute call, report structure. |
| version | 1.6.1 |
Cross-cutting concerns live elsewhere — read them first:
query-database— how to run prod queries against Postgres and BigQuery (bqprefix). Most of the tables below are Postgres; the BigQuery section adds the LLM-enriched and modeled layers Postgres doesn't have.tex-preferences(rule) — title page, monochrome underlined links via\link{url}{text}, date formatting, compilation withtectonic. Do not usecolorlinks=true— the rule is monochrome with\uline{}.coda-mcp-gotchas— pulling pages from the Worker & Customer Research doc.- The cyrano subagent owns Slack delivery and the threaded-versioning DM pattern for drafts.
The Worker Interview Program is documented at coda://docs/EWmUjDXMMz (page IDs: section-HLk0LfQy8d Program, section-QSaEUjHbeA Interview Guide, section-Tw9iM5b4ti Worker Friendies). Pull the program page once per session for:
- Segments (Flex vs Consistent) and lifecycle states (Unactivated → New → Underserved → Active → Fading → Dormant → Churned). Use these internally to inform the framing of the lede and interview angles. Don't print them in the report itself — they read as bureaucratic. See "Report structure" below.
- Priority order: Active / Fading / Dormant first.
- Universal questions (six, asked at the end of every interview): pick-a-shift drivers, non-shift pain points, non-shift help, other-agency exclusivity, referral triggers, exit triggers.
These will eat 4+ query attempts if you guess:
workershas nostatuscolumn. Account state lives inaccount_status, joined byid = workerId(not by aworkerIdcolumn). Fields:accountStatus,approvalStatusReason,terminatesAt,employmentType,phoneNumberStatus.- The strike table is
strike, singular. Notstrikes, notworker_strikes. Rich enum onreason:NO_CALL_NO_SHOW,LAST_MINUTE_NOTICE_SHIFT_CANCELLATION,VERY_SHORT_NOTICE_SHIFT_CANCELLATION,SHORT_NOTICE_SHIFT_CANCELLATION. - Past experience table is
worker_past_experiences. Columns:businessName,positionName,lengthOfEmployment,agencyName(set if the gig was via Instawork etc.). worker_attributes(the new V3 attribute system) is sparsely populated. For most workers it's empty. Use the legacypast_experiencestable — fieldscategory,profileField,value,opsLevel,aiLevel,resumeLevel— for the lifting / responsibilities / skills tags.companies.industrydoesn't exist. Usecategory(e.g.BEVERAGE_PRODUCTION).shifts.supervisorIdresolves to aUserrow, often the on-site customer manager (roleSUPERVISOR, email at the customer's domain) — not a Traba ops user. This is the named human the worker actually deals with day-to-day. Worth surfacing in the report; reference them by first name in interview angles.
Most of the report writes itself if you query these in order:
worker_metrics— Tier, lifetime feedback score, perfect-shifts streak.work_preferences—hoursPerWeek,shiftConsistency(ONE_OFFvs recurring),transportation. Their stated preference is often misaligned with their actual behavior — call out the gap.worker_shifts×shifts×companies— the company breakdown by shift count / completed / canceled / no-show. Always join all three; rawworker_shifts.companyIddoesn't exist (it's onshifts).connections— business-sideFAVORITE/BLOCK/INELIGIBLEwith operatornoteandinitiatedBy. The who of customer love and customer rejection.initiatedByis a free-text name field; cross-reference against the customer's user list to confirm it's an on-site person.company_worker_feedback— post-shift business→worker rating (rating1–5) with optionalnote. Often has direct customer quotes that read as pull-quotes ("He left the production without letting anybody know…"). SourceCOMPANYis the in-app rating; other sources are ops-entered.worker_feedback— worker→business feedback. Reasons array (HELPFUL_SUPERVISORS,ENJOYABLE_ENVIRONMENT, etc.) plus optionalinfotext. Sparse but high-signal.worker_churn_risk— AI-generated voluntary/involuntary scores per worker-company pair, plus prose fields (churnRiskWorkerFeedbackDetails,churnRiskCompanyFeedbackDetails,suggestedActionsDetails). The model has often already drafted half the narrative for you.involuntaryChurnRiskScore = 0whilevoluntaryChurnRiskScorefluctuates means "the customer wants them, the worker is drifting" — a clean Active→Fading signal.strike— patterns over time matter more than counts. Cluster recent NCNS in months; if they all hit the same company, the relationship is the story.worker_shifts.cancellationReasonandcancellationSource— the verbatim reason text is often gold ("I found a better paying work opportunity today"). Pull recent cancels with the reason and source separately; many cancels areOPSsource (not the worker's fault) and shouldn't be counted against them.worker_applications×application_entities×shift_requests/roles×companies— every shift the worker has been invited to or applied to, even ones they didn't take. Often more revealing than the shifts they actually worked. See "Applications" below.worker_vetting_session+worker_vetting_phone_calls— AI-vetting outputs includingsummary(a paragraph of LLM-generated narrative from the call transcript),evalScore(0–1), and call status (VETTING_COMPLETE/MISSED_OUTBOUND/VETTING_INCOMPLETE). The summaries surface stated experience, pay expectations, commute, and red flags that don't appear anywhere else. See "Vetting calls" below.chat_messageswhereworkerSenderId = workerId— the worker's own voice in the in-app chat. Filter by sender to read what the worker has actually said to ops or to customer supervisors. Often the most direct evidence of relationship dynamics and pain points. See "Chat messages" below.intercom_ticket— support tickets the worker has opened.ticketTypeNameis the categorization ([Pay] Time dispute,[Gen] Other issues, etc.);ticketStateshows whether it was resolved. The count and type matter even when descriptions are empty in our copy.worker_facts— heavily populated (200+ rows per active worker) with system-generatedPREFERENCErows:ROLES_V1,PAY_RATE_V1,SCHEDULE_DAY_AVAILABILITY_V1,COMMUTE_V1,SCHEDULE_CONSISTENCY_V1. Thedatafield is JSON. The most-recent fact for each preference type reflects what was captured in the worker's most recent app/vetting interaction — so a worker whose pay range jumps from$16 fixedto$16–$25 rangeovernight is signaling pay flexibility on their latest vetting call. Compare today's facts to a month ago.worker_resumes+worker_resume_scores— uploaded resume plus AI-scored experience breakdown.priorEmployersis JSON with{employerName, tenureMonths}rows; per-role-typemonthsOfExperiencecolumns (forklift, lumper, machine operator, packer);fullTimeWorkHistoryScoreandtenureAtPastEmployersScore. Often surfaces experience the worker never entered in their profile.
The most important signal-quality distinction across all worker data:
- Manual actions are high signal. A customer favoriting a worker, blocking a worker, or rating them in the app is a deliberate human act. So is an ops operator leaving a
noteon a connection or invalidating a strike. So is a worker writing achat_messagesrow, opening anintercom_ticket, or canceling a shift with a typed reason. Every one of these is worth surfacing verbatim. - Bulk actions are low signal individually. Ops invites (
OPS_INVITE), worker self-applications (TRABA_APP_WORKER_APPLY), shift invitations (shift_invitations), and outreach attempts (outreach_attempts) are mostly automated or spray-and-pray. A single record from any of these is meaningless. The volume of any single source means little. - Bulk actions become high signal as ratios over a meaningful sample. "Worker submitted 50 self-applications and 47 were screened ineligible" is signal — they're trying and getting rejected. "Worker has 88 ops invites at one customer and accepted zero of them" is signal — they're choosing not to. The narrative comes from the conversion or rejection rate, not the count.
Apply this filter when writing the report: every manual-action row deserves a quote or a sentence; every bulk-action count needs a paired conversion/rejection rate before it earns mention.
worker_applications is the table that tells you what work was offered to the worker, not what they did. For a worker stuck on one customer, this is often the most informative table in the database.
Join chain: worker_applications.applicationEntityId → application_entities → either shiftRequestId (then shift_requests.companyId and roleId) or roleId directly. Roles join to companies via roles.companyId. Build a COALESCE on the company name from both paths.
acquisitionSource is the most useful column. Distribution tells you who is driving the worker's pipeline:
OPS_INVITE— Traba ops actively inviting them. High volume = ops trying to diversify them.BIZ_INVITE— customer asked for them by name. High volume from one customer = strong relationship.TRABA_APP_WORKER_APPLY— the worker self-applied. The clearest signal of worker-driven intent.DYNAMIC_SHIFT_FILLING_INVITE— backfill auto-invite.WORKER_WAITLISTED— worker signed up for a waitlist.VETTING_CAMPAIGN— came through an ops vetting campaign.
status and result tell you outcomes: COMPLETED_ELIGIBLE means they're cleared to take it, COMPLETED_INELIGIBLE means screened out, IN_PROGRESS means mid-process. A pile of COMPLETED_ELIGIBLE applications they never converted to shifts is its own signal — they had the option and didn't take it.
Per the manual-vs-bulk principle, raw application counts are not high signal — they're bulk pipeline output. What is high signal:
- Acceptance/conversion rate per company. A worker eligible for 88 invites at one customer who has accepted exactly zero is real signal — they're being told no in some way, or saying no in some way. Compare invites-eligible to actual shifts worked at that customer.
- Rejection rates over a sample.
COMPLETED_INELIGIBLEplusresult=ARCHIVEDwithresultReason. A worker submitting many applications that get screened out is having a bad app experience and is likely frustrated about it on the call. The ratio is the signal — "submits 50 apps, 47 ineligible" tells you something the count alone doesn't. - Recency clusters of self-applications. "168 self-apps to one new customer in the last three weeks" is different from "168 self-apps spread over a year." Tight clusters mean active intent right now; flat distributions mean they've always been clicking around.
- The roles being invited or applied to. Pattern of role types tells you what work they're being matched against, which surfaces a question about whether the matching feels right.
- Inviting customer history. If ops keeps inviting them to a customer they've been blocked at or rated 1/5 by, that's an ops-side observation to flag in the report (not a question to ask the worker).
worker_vetting_phone_calls (joined through worker_vetting_session.workerId) is the AI-vetting layer. Two fields are gold:
summary— a paragraph of LLM-generated narrative from the call. Surfaces stated experience ("6 years of warehouse experience"), pay expectations ("$19/hour matching the offer"), commute ("carpools, 30-minute commute"), schedule preference, and "red flags" the model identified. Often reveals more than the worker's own profile.evalScore— 0–1 confidence the worker is qualified. Useful but the summary text matters more.
Status counts also matter. Multiple MISSED_OUTBOUND records on the same day mean ops tried to reach them several times and failed — a responsiveness signal. VETTING_INCOMPLETE with summary "Insufficient transcript data" usually means a hangup or bad audio.
Data-quality gotcha: name transcription is unreliable. The AI agent regularly garbles the worker's name in the summary text — Matt Lutze became "Matthew Lott", Temeka Givens became "Sneaker Kids" / "Malika Kevin" / "Tamiko" across different calls. The substance of the summary is usually about the right worker (the join is reliable through workerVettingSessionId → workerId), but don't quote the literal name from the transcript prose. Read past it.
Pull chat_messages where workerSenderId = workerId (note: workerId doesn't exist on this table; it's workerSenderId for worker-sent and companyUserSenderId for company-sent). The worker's own voice in the app is often the most direct evidence of the relationship.
Look for:
- Pay disputes. Workers asking about not being paid, time entries not going through, clock-in failures, manual time entry. Often correlates with
intercom_ticketrecords of type[Pay] Time dispute. - Names of supervisors not in our
userstable. Workers reference floor leads, shift managers, on-site contacts by first name ("Ryan", "Ant", "Uniquia"). These are real humans the worker deals with daily who don't appear anywhere else in the schema. - Constraints not in
work_preferences. "no I got church tomorrow", "I have to leave my phone in the locker", schedule conflicts that won't show up in formal availability. - Tone. Friendly chitchat versus frustration versus pleading versus terse one-word responses. Tells you how they experience the platform.
Volume itself is a signal. Two messages over two years (Matt) means they don't use the chat layer. Twenty messages including a multi-day pay dispute (Temeka) means they actively run the relationship through the app — and any unresolved issues are visible there.
worker_facts is heavily populated and looks redundant on first read — hundreds of PREFERENCE rows that mostly say the same thing. The trick is comparing the most recent fact for each preference type to the older facts of the same type:
- A worker whose
PAY_RATE_V1wasmin: 16for months and is nowmin: 16, max: 25told a vetting agent in the last few days they're open to higher pay. - A worker whose
SCHEDULE_DAY_AVAILABILITY_V1was M–F 8am–5pm and is now 7 days/week including overnight signaled wide-open availability on their last interaction. - A
COMMUTE_V1of5 minutesfrom a worker whose home zip is 30 miles from the customer is either a misstatement or a transcript error from the vetting call — worth flagging.
Don't dump the fact list in the report; extract the change between recent and older facts as one or two sentences.
Postgres has the operational state. BigQuery has the analytics warehouse, including LLM-enriched columns and pre-joined marts that Postgres doesn't have. Always check BQ for these in addition to Postgres:
-
traba-app.marts.worker_shifts__cancel_categories— for every canceled worker-shift, an LLM has flagged the cancel reason against ~30 categories. Boolean flags includecancellation_category_health_flag,cancellation_category_sickness_flag,cancellation_category_transportation_issue_flag,cancellation_category_childcare_flag,cancellation_category_pay_errors_flag,cancellation_category_bad_supervisor_flag,cancellation_category_found_other_work_flag,cancellation_category_better_worker_match_flag,cancellation_category_too_far_flag,cancellation_category_extreme_temperature_flag, etc. The categorization is sparse (only fires when the cancel text or context maps cleanly), but each fire is a high-signal LLM judgment about why the worker missed. Group by category and surface in the report. Note: the LLM occasionally mis-categorizes (e.g. flags a medical cancel as "found other work") so treat each flag as a hypothesis, not a verdict — always cross-check against the PostgrescancellationReasontext. -
traba-app.marts.worker_shifts_vetting_enrichment— every worker-shift joined with vetting status,isHired(do they have a job placement at this customer?),isVetted,vetter(Traba employee who vetted them),wasBlockedflag (was an ineligible connection created right after this shift?),wasIneligibled,runningCompletesandrunningHoursWorked(cumulative at that customer at the time of the shift), andshift_difficulty_score. Useful for understanding progression — a worker hittingwasBlocked = trueon a shift tells you the block came from that specific shift, not nowhere. -
traba-app.marts.placements— surrogate-key(worker_id, company_id, region_id)placements withplacement_first_*columns describing how the relationship started (invite type, signup status, whether it was recurring/infinite schedule, role family). Tells you the origin story of a worker-customer pair. -
traba-app.metrics.placements_by_day__churn— per-placement churn timing. -
traba-app.metrics.monthly__worker_ltv— modeled lifetime value per worker. -
traba-app.intercom.conversation_part_history— full Intercom message bodies. Postgresintercom_ticketonly has metadata; BQ has the actual conversation text. Use this when ticket counts in Postgres look interesting and you want to read what the worker actually wrote. -
traba-app.freshdesk.conversation— Freshdesk tickets with body text. -
traba-app.worker_app.*— 320+ Segment-tracked event tables for the worker mobile app (every tap, screen-load, application step). Use sparingly — naming is consistent (application_step_<name>_clicked,shift_<event>_view) but the breadth is overwhelming. Worth a focused query when investigating a specific app-experience hypothesis ("did they ever land on the help screen for pay disputes?") rather than a broad scan. -
traba-app.firestore_export_prod.timesheets_raw_latest— supervisor-app timekeeping events (clock-in, clock-out, breaks). Postgres has the resolvedworker_shift.timeWorked; BQ has the raw events behind it. Useful when investigating clock-in failures or pay disputes (Temeka-style).
Casing changes between sources: in BQ marts, columns are snake_case (worker_id, shift_date); in src_pg, they preserve Postgres camelCase (workerId). Reference tables with backticks: `traba-app.marts.worker_shifts__cancel_categories`.
A few tables look promising and aren't:
outreach_attempts(1000+ rows per worker) — mostly automated dynamic-fill outreach with little narrative content. Skip unless you have a specific question about responsiveness windows.worker_communication(3000+ rows per worker) — combined SMS / push / email / AI-call log. Volume is huge and most of it is automated. Consider filtering by source/medium if you have a specific question.shift_invitations(hundreds per worker) — direct shift invites. Counts mirrorworker_applicationsand don't usually add narrative beyond it.worker_uploads— typically zero rows for active workers. Workers don't upload media in the normal flow.
For Consistent-segment workers, look at shifts.slotsRequested over time at their dominant company:
- 1 slot/day, 5 days/week → the worker is the customer's outsourced employee. Their no-show is a 100% staffing failure.
- Many slots/day → they're one of N interchangeable workers. Different relationship dynamic; the customer can absorb a no-show.
State this explicitly ("X is a 1-slotter at Y") because it changes the empathy frame for the call.
A worker with 141 canceled shifts is not necessarily flaky. Group by cancellationSource:
WORKER— their cancel. Pull the verbatimcancellationReason.BUSINESS— customer canceled (weather, demand drop, "biz req").OPS— Traba ops canceled. Often a mass placement teardown that hits one worker hard; flag and exclude from any "this worker is unreliable" framing.SYSTEM— auto-cancellation (e.g., expired pending shift).
The Legendairy mass-cancel pattern — 21 ops cancellations across consecutive days at one company — looks awful in a count and is meaningless in narrative. Always disaggregate.
For the report:
- Worker:
https://console.traba.work/workers/<workerId> - Company:
https://console.traba.work/companies/<companyId>
(The ops console used to live at ops.traba.work; the canonical domain is now console.traba.work. Use the new domain in all new reports.)
Per tex-preferences, render as underlined \link{url}{Name} — never paste raw IDs in the prose. Define \workerlink and \colink macros at the top of the report.
The user wants a single readable page about the worker, with detail in appendices. Front-load substance, not stats:
Page 1 (the one-pager):
- Lede paragraph. Open with the tension or insight, not facts the snapshot is about to repeat. The job of the lede is to make the reader want to take the call. Use the lifecycle insight (e.g., "the customer wants him; the worker is starting to drift") without naming the program framework — drop "Consistent-segment", "Active→Fading", "P0 priority". Those are internal taxonomy and read as bureaucratic in the brief.
- Snapshot table. Two columns of the load-bearing facts: profile (with ops-console link), home base, primary account + supervisor, schedule, pay, tenure, acquisition. Don't include rows the lede already covered (no "Archetype" row, no "Segment/state" row).
- What their week actually looks like. 3–4 bullets translating the data into lived experience. For workers on a recurring placement: emphasize that "the app is a timeclock + paystub" — the marketplace framing doesn't apply, which changes how the standard questions land.
- Two questions worth a short call. The user's instruction is exclusive, not comprehensive. A 15-min call has room for two worker-specific opens before the universal six. Pick angles that test the central hypothesis. Don't list every interesting thread; everything else is appendix material.
- One short practical line — worker-specific intel only. The principle: include only what the caller wouldn't already have from being on the team. Phone number, language preference if non-default, anything genuinely unusual about how to reach them. Skip anything procedural — "text first per the SOP", "don't call during their shift window", "twice/day", "24h escalation". The caller's job is the call; you're not teaching them how to do it. Often this whole line is unnecessary; only add it if there's truly worker-specific intel (e.g., "Spanish-preferred", "best window 8--10am ET because of childcare").
Appendices: reliability deep-dive (NCNS clusters, churn-risk model output), customer-side signals (favorites/blocks/worst review with quotes), companies-worked-at table, customer ratings chronological, strike ledger, worker's own feedback to Traba (verbatim), past experience.
The threshold for "worth surfacing on page 1" is high: it has to change the call's opening question or shift the empathy frame. Everything else goes to an appendix.
The lede paragraph is where iterations get lost. Common failure: it ends up restating the snapshot in prose ("Matt is a 31-month veteran in Austin, who works at X for Y hours…"). Instead, lead with the tension that makes the worker interesting:
- "By the data, X is both the most reliable and least reliable worker on this list — depending on which months you look at."
- "X has worked Y 154 times in the last year. Y still wants more shifts from him; X has been quietly missing more of them."
- "On paper, X is a textbook Active worker. In practice, he's started skipping the same shift type at the same customer four months in a row."
The insight should be earned from the data, not a label imported from the program framework. If you find yourself writing "is a P0 priority because the segmentation says…", delete and rewrite from the data.
A one-line archetype label can be useful in the snapshot or interview-prep section. Don't make it a section heading or a separate row in the snapshot table — that reads as overformal. Examples:
- "The Dedicated 1-Slot Anchor" — recurring worker as customer's full-time employee.
- "The Multi-Account Bouncer" — no dominant account, high recency across many.
- "The Roster Fade" — was anchored, has drifted, data hasn't acknowledged it yet.
- "The Pre-Quit" — active by completion rate, but cancel/no-show pattern says they're already gone.
If the lede paragraph already conveys the archetype implicitly, drop the label.
Front-load recent activity (last 30–90 days). Older history goes to appendices. The caller is talking to the worker now — what happened twelve months ago is context, what happened last week is the conversation. Specific applications:
- The "what their week looks like" bullets should describe their current schedule, not a lifetime average.
- Cancel/no-show framing should lead with the most recent cluster, not the lifetime count.
- Customer relationship signals: most recent rating and most recent favorite/block matter more than the lifetime ledger.
- The companies-worked-at table belongs in an appendix, not on page 1, unless the worker bounces across many.
NCNS counts read like a smoking gun and usually aren't. Before framing them as a drift signal, sanity-check three things:
- Are they concentrated at one customer? For a worker with one dominant account, "all NCNS at X" is tautological — they have no other shifts to no-show on. The interesting question is whether the rate at that customer is changing over time, not the absolute count.
- Are they clustered or distributed? A 4-in-4-days cluster reads like a life event (illness, family, transit failure). Evenly distributed misses read more like erosion.
- Has the strike been invalidated or proven? Look at
strike.provenandstrike.invalidatedBy. System-invalidated strikes (e.g., shift later canceled by ops) shouldn't count toward the narrative.
Also: cluster + recent return to perfect attendance ≠ drift. Often the worker handled whatever it was and is back. State the data; let the call test the meaning. Avoid words like "drifting", "fading", "pre-quit" in the report — those are interpretations the user is going to push back on, and they pre-empt the call's job.
Temeka Givens had 66 completed shifts at QuickBox Fulfillment with zero company_worker_feedback rows. That looked like a productive interview thread — "are you treated as faceless labor by this customer?" — but it's n=1 in this skill so far. Worth probing on the call when you see it; worth building a base rate before turning it into a rule. If you see a second case, validate or revise.
Per tex-preferences, links are underlined via \uline{}. Don't underline a link inside a \Large+ bold title — the descenders collide with the underline and look broken. Drop the link from the title; put it in the snapshot table instead as "Profile: \workerlink{Open in ops console}".
Spacing gotcha: a negative \vspace immediately before a \hrule will pull the rule up into the subtitle line. Use positive spacing (\vspace{0.5em} before, \vspace{0.7em} after), or skip the rule entirely and rely on visual hierarchy from the type sizes. The latter is usually cleaner for a one-pager.
Send via cyrano with the threaded-versioning DM pattern (parent message with summary blurb, file uploads as thread replies labeled v1/v2/...). Expect ~3–5 iterations: framing, length, formality, link rendering, lede dedup. The data work is usually right by v1; the form is what gets refined.