@synap-core/workspace-templates 0.9.4 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +95 -11
- package/dist/index.js +2018 -467
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/default-relation-defs.json +208 -0
- package/src/__fixtures__/report-automation-flow.json +393 -0
- package/src/agent-fleet.yaml +6 -1
- package/src/base.template.test.ts +160 -0
- package/src/base.yaml +839 -0
- package/src/business-developer.template.test.ts +79 -10
- package/src/business-developer.yaml +61 -39
- package/src/concept-guard.test.ts +47 -31
- package/src/concept-guard.ts +23 -19
- package/src/content-os.yaml +22 -0
- package/src/corpus-flow-validity.test.ts +130 -0
- package/src/crm.yaml +59 -8
- package/src/define.primary-surface.test.ts +2 -4
- package/src/define.ts +28 -0
- package/src/ecosystem.yaml +14 -0
- package/src/enterprise-os.yaml +1 -1
- package/src/foundation.yaml +8 -0
- package/src/grants.yaml +8 -0
- package/src/life-os.yaml +6 -1
- package/src/operations.yaml +270 -0
- package/src/outreach-comms.yaml +196 -0
- package/src/project-management.yaml +6 -1
- package/src/templates.ts +1659 -373
- package/src/types.ts +66 -0
- package/src/validate-flow.ts +186 -7
- package/src/validate.ts +26 -2
package/src/operations.yaml
CHANGED
|
@@ -212,6 +212,22 @@ entityLinks:
|
|
|
212
212
|
targetProfileSlug: company
|
|
213
213
|
type: briefing_about_client
|
|
214
214
|
label: Client
|
|
215
|
+
# The CRM → Operations handoff edge. A `client` is a ROLE facet on a company
|
|
216
|
+
# OR a person (foundation's `applicableKinds`), so both endpoint pairs are
|
|
217
|
+
# declared — a solo-trader client is a person, and the pickup automation must
|
|
218
|
+
# be able to link its engagement either way.
|
|
219
|
+
- sourceProfileSlug: engagement
|
|
220
|
+
targetProfileSlug: company
|
|
221
|
+
type: engagement_for_client
|
|
222
|
+
label: Client
|
|
223
|
+
- sourceProfileSlug: engagement
|
|
224
|
+
targetProfileSlug: person
|
|
225
|
+
type: engagement_for_client
|
|
226
|
+
label: Client
|
|
227
|
+
- sourceProfileSlug: contract
|
|
228
|
+
targetProfileSlug: engagement
|
|
229
|
+
type: contract_for_engagement
|
|
230
|
+
label: Engagement
|
|
215
231
|
|
|
216
232
|
# CURATED views — the delivery motion, not one per profile
|
|
217
233
|
views:
|
|
@@ -571,3 +587,257 @@ automations:
|
|
|
571
587
|
- { id: e-write-link, source: write-briefing, target: link-briefing }
|
|
572
588
|
- { id: e-link-resolve, source: link-briefing, target: resolve-team }
|
|
573
589
|
- { id: e-resolve-post, source: resolve-team, target: post-recap }
|
|
590
|
+
|
|
591
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
592
|
+
# CRM → Operations HANDOFF PICKUP
|
|
593
|
+
#
|
|
594
|
+
# The other half of business-developer's "Win and hand off". That flow ends by
|
|
595
|
+
# attaching the pod-wide `client` role with `handoffStatus`; nothing on the
|
|
596
|
+
# delivery side ever picked it up, so a won deal became a client and then
|
|
597
|
+
# nothing happened. This automation is that pickup: once a day it finds
|
|
598
|
+
# clients marked ready/handed_off that have NO engagement yet, and opens the
|
|
599
|
+
# delivery shell — a `proposed` engagement plus a `draft` contract.
|
|
600
|
+
#
|
|
601
|
+
# WHY THE SHARED `client` SLUG MATTERS: this reads foundation's pod-wide
|
|
602
|
+
# `client` role. When the CRM wrote a workspace-scoped `crm-client` instead,
|
|
603
|
+
# this query could not have seen a single handed-off party — the two halves of
|
|
604
|
+
# the handoff were spelling the same concept differently. Collapsing the fork
|
|
605
|
+
# is what makes this automation possible at all.
|
|
606
|
+
#
|
|
607
|
+
# WHY EVERY NODE TYPE IS WHAT IT IS (loop-body grammar, automation-executor.ts
|
|
608
|
+
# `LOOP_BODY_NODE_TYPES`): the per-client body may only contain
|
|
609
|
+
# command / output / condition / skill / capability / playbook_run /
|
|
610
|
+
# messages_query / query / fetch / transform. `switch`, `entity_read` and
|
|
611
|
+
# `related_entities` are BOUNDARY types — putting one in the chain would end
|
|
612
|
+
# the loop body there and silently run the rest ONCE for the whole run. That
|
|
613
|
+
# is why the two gates are `condition` nodes (per-item continue-semantics) and
|
|
614
|
+
# why the "does an engagement already exist?" lookup is a `capability`
|
|
615
|
+
# entity.query rather than a `related_entities` node.
|
|
616
|
+
#
|
|
617
|
+
# IDEMPOTENCY is per-client and does not rely on the gate alone:
|
|
618
|
+
# `dedupeBy: engagement-client` makes a second run for the same client a
|
|
619
|
+
# no-op SKIP before the governance gate is even reached, and the contract
|
|
620
|
+
# dedupes on `contract-engagement`. Both keys are real property defs on the
|
|
621
|
+
# profiles above, not invented ones.
|
|
622
|
+
#
|
|
623
|
+
# ── GOVERNANCE, HONESTLY ──────────────────────────────────────────────────
|
|
624
|
+
# `metadata.governance.forceProposeWrites` is deliberately NOT set here, and
|
|
625
|
+
# that is not an oversight. That marker is read in exactly ONE place —
|
|
626
|
+
# `deriveSessionForceProposeGovernance` (synap-backend
|
|
627
|
+
# `packages/api/src/utils/permission-check.ts`), off a FOCUS SESSION stamped
|
|
628
|
+
# by `executePlaybookRun`. It is a PLAYBOOK mechanism. A flow automation's
|
|
629
|
+
# `output/entity_create` goes through `checkAutomationWriteOrPropose`
|
|
630
|
+
# (packages/jobs/src/utils/automation-governance.ts), which never looks at
|
|
631
|
+
# automation metadata, so the field would be inert decoration. Worse: the
|
|
632
|
+
# automation propose path is currently BROKEN end-to-end (proposeAutomationWrite
|
|
633
|
+
# persists `data` FLAT while the approve executor reads a nested envelope —
|
|
634
|
+
# documented at automation-executor.ts, "KNOWN PRE-EXISTING BUG"), so forcing
|
|
635
|
+
# proposals here would produce reviews that throw on approve.
|
|
636
|
+
#
|
|
637
|
+
# The reviewability is carried by STATE instead: the engagement is created
|
|
638
|
+
# `proposed` and the contract `draft`. Nothing is active until a human moves
|
|
639
|
+
# it. If genuine proposal-gating is wanted, the canonical door is a PLAYBOOK
|
|
640
|
+
# with `metadata.governance.forceProposeWrites: true` + a `schedule` (the CRM
|
|
641
|
+
# Hygiene precedent in crm.yaml) — a different, agent-authored design.
|
|
642
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
643
|
+
- key: operations-crm-handoff-pickup
|
|
644
|
+
name: Pick up handed-off clients
|
|
645
|
+
description: >-
|
|
646
|
+
Daily. Finds pod-wide clients the CRM marked ready or handed off that have
|
|
647
|
+
no engagement yet, and opens the delivery shell for them — a proposed
|
|
648
|
+
engagement plus a draft contract, both linked to the client. Deduped per
|
|
649
|
+
client, so it is safe to re-run.
|
|
650
|
+
triggerType: cron
|
|
651
|
+
triggerConfig:
|
|
652
|
+
expression: "0 7 * * *"
|
|
653
|
+
status: active
|
|
654
|
+
metadata:
|
|
655
|
+
templateKey: operations-crm-handoff-pickup
|
|
656
|
+
# Same pod-wide dependency as the briefing automation: `client` entities
|
|
657
|
+
# are pod-wide, and `scope: pod` is the explicit opt-in that must be
|
|
658
|
+
# DEPLOYED for this query to return anything.
|
|
659
|
+
requiresPodWideEntityQueryLens: true
|
|
660
|
+
flow:
|
|
661
|
+
nodes:
|
|
662
|
+
- id: trigger
|
|
663
|
+
type: trigger
|
|
664
|
+
position: { x: 0, y: 0 }
|
|
665
|
+
data:
|
|
666
|
+
label: Daily at 07:00
|
|
667
|
+
triggerType: cron
|
|
668
|
+
config:
|
|
669
|
+
expression: "0 7 * * *"
|
|
670
|
+
|
|
671
|
+
# READ-ONLY builtin. Enumerates parties carrying the pod-wide `client`
|
|
672
|
+
# ROLE. `scope: pod` is required — see the long note on the briefing
|
|
673
|
+
# automation's `list-clients` node.
|
|
674
|
+
- id: list-clients
|
|
675
|
+
type: capability
|
|
676
|
+
position: { x: 0, y: 120 }
|
|
677
|
+
data:
|
|
678
|
+
label: List clients (pod-wide `client` role)
|
|
679
|
+
verbId: entity.query
|
|
680
|
+
inputMapping:
|
|
681
|
+
profileSlug: client
|
|
682
|
+
scope: pod
|
|
683
|
+
limit: "100"
|
|
684
|
+
|
|
685
|
+
- id: handoff-loop
|
|
686
|
+
type: loop
|
|
687
|
+
position: { x: 0, y: 240 }
|
|
688
|
+
data:
|
|
689
|
+
label: For each client
|
|
690
|
+
iteratorExpression: steps.list-clients.output.entities
|
|
691
|
+
itemVariable: client
|
|
692
|
+
|
|
693
|
+
# READ-ONLY builtin. `handoffStatus` lives on the client FACET row, not
|
|
694
|
+
# on the entity — `entity.query`'s `filter` reads `entities.properties`
|
|
695
|
+
# and therefore cannot see it. This is the only door that returns facet
|
|
696
|
+
# overlay properties.
|
|
697
|
+
- id: read-client-facets
|
|
698
|
+
type: capability
|
|
699
|
+
position: { x: 0, y: 360 }
|
|
700
|
+
data:
|
|
701
|
+
label: Read this party's role facets
|
|
702
|
+
verbId: entity_facet.list
|
|
703
|
+
inputMapping:
|
|
704
|
+
entityId: "{{loop.item.id}}"
|
|
705
|
+
|
|
706
|
+
# Pull the CLIENT facet's handoffStatus out BY SLUG. Positional
|
|
707
|
+
# indexing (`facets.0.properties…`) would be a latent bug: facet order
|
|
708
|
+
# is not guaranteed, so on a party that is also a partner it would read
|
|
709
|
+
# the wrong row. `filter` + `map` resolves it by identity.
|
|
710
|
+
- id: client-handoff-status
|
|
711
|
+
type: transform
|
|
712
|
+
position: { x: 0, y: 480 }
|
|
713
|
+
data:
|
|
714
|
+
label: Read the client facet's handoff status
|
|
715
|
+
expression: "{{steps.read-client-facets.output.facets}} | filter:item.profileSlug === client | map:{{item.properties.handoffStatus}}"
|
|
716
|
+
|
|
717
|
+
# PER-ITEM FILTER (continue-semantics). `pending` means the CRM has not
|
|
718
|
+
# finished with this client yet — only ready/handed_off are ours.
|
|
719
|
+
- id: handoff-gate
|
|
720
|
+
type: condition
|
|
721
|
+
position: { x: 0, y: 600 }
|
|
722
|
+
data:
|
|
723
|
+
label: Only clients the CRM marked ready or handed off
|
|
724
|
+
expression: "steps.client-handoff-status.output.result contains-any ready,handed_off"
|
|
725
|
+
|
|
726
|
+
# Pure read, workspace-lensed (engagements are Operations-local, so no
|
|
727
|
+
# `scope: pod`). `engagement-client` is a real property def above, so
|
|
728
|
+
# this equality filter hits `entities.properties`.
|
|
729
|
+
#
|
|
730
|
+
# `filter` is authored as a JSON *STRING*, not a mapping, and that is
|
|
731
|
+
# load-bearing: `parseQueryFilterConditions` (automation-executor.ts)
|
|
732
|
+
# template-resolves the filter ONLY when it is a string. Written as a
|
|
733
|
+
# YAML object, `{{loop.item.id}}` would reach SQL as that literal text,
|
|
734
|
+
# match nothing, and this gate would then open for EVERY client.
|
|
735
|
+
- id: existing-engagements
|
|
736
|
+
type: query
|
|
737
|
+
position: { x: 0, y: 720 }
|
|
738
|
+
data:
|
|
739
|
+
label: Does this client already have an engagement?
|
|
740
|
+
profileSlug: engagement
|
|
741
|
+
filter: '{"engagement-client": "{{loop.item.id}}"}'
|
|
742
|
+
limit: 1
|
|
743
|
+
|
|
744
|
+
- id: engagement-gate
|
|
745
|
+
type: condition
|
|
746
|
+
position: { x: 0, y: 840 }
|
|
747
|
+
data:
|
|
748
|
+
label: Only clients with no engagement yet
|
|
749
|
+
expression: "steps.existing-engagements.output.count === 0"
|
|
750
|
+
|
|
751
|
+
# Cron trigger carries no payload timestamp, so `now` falls back to the
|
|
752
|
+
# actual run time — stamping the handoff moment onto both records
|
|
753
|
+
# below so they plot on the calendar the day they're handed off.
|
|
754
|
+
# `engagement-end`/`start`-adjacent dates the owner still sets by hand
|
|
755
|
+
# stay unset.
|
|
756
|
+
- id: handoff-time
|
|
757
|
+
type: compute
|
|
758
|
+
position: { x: 0, y: 900 }
|
|
759
|
+
data:
|
|
760
|
+
label: Record handoff time
|
|
761
|
+
operation: now
|
|
762
|
+
|
|
763
|
+
# GOVERNED WRITE via an `output` node. Created `proposed`, never active:
|
|
764
|
+
# the delivery shell exists so the work is visible, but a human decides
|
|
765
|
+
# when it starts. `dedupeBy: engagement-client` is the durable
|
|
766
|
+
# per-client idempotency key — a re-run SKIPS before the governance gate.
|
|
767
|
+
- id: create-engagement
|
|
768
|
+
type: output
|
|
769
|
+
position: { x: 0, y: 960 }
|
|
770
|
+
data:
|
|
771
|
+
label: Open a proposed engagement
|
|
772
|
+
outputType: entity_create
|
|
773
|
+
config:
|
|
774
|
+
profileSlug: engagement
|
|
775
|
+
title: "Engagement — {{loop.item.title}}"
|
|
776
|
+
dedupeBy: engagement-client
|
|
777
|
+
properties:
|
|
778
|
+
engagement-status: proposed
|
|
779
|
+
engagement-client: "{{loop.item.id}}"
|
|
780
|
+
engagement-start: "{{steps.handoff-time.output.result}}"
|
|
781
|
+
engagement-notes: "Opened automatically from the CRM handoff. Confirm scope, dates, and owner before activating."
|
|
782
|
+
|
|
783
|
+
- id: link-engagement
|
|
784
|
+
type: output
|
|
785
|
+
position: { x: 0, y: 1080 }
|
|
786
|
+
data:
|
|
787
|
+
label: Link engagement → client
|
|
788
|
+
outputType: relation_create
|
|
789
|
+
config:
|
|
790
|
+
fromEntityId: "{{steps.create-engagement.output.entityId}}"
|
|
791
|
+
toEntityId: "{{loop.item.id}}"
|
|
792
|
+
relationType: engagement_for_client
|
|
793
|
+
|
|
794
|
+
# Draft contract, deduped on the engagement it governs. A skipped
|
|
795
|
+
# (duplicate) engagement create still returns the existing entityId, so
|
|
796
|
+
# this stays correctly anchored on a partial re-run.
|
|
797
|
+
- id: create-contract
|
|
798
|
+
type: output
|
|
799
|
+
position: { x: 0, y: 1200 }
|
|
800
|
+
data:
|
|
801
|
+
label: Open a draft contract
|
|
802
|
+
outputType: entity_create
|
|
803
|
+
config:
|
|
804
|
+
profileSlug: contract
|
|
805
|
+
title: "Contract — {{loop.item.title}}"
|
|
806
|
+
dedupeBy: contract-engagement
|
|
807
|
+
properties:
|
|
808
|
+
contract-status: draft
|
|
809
|
+
contract-engagement: "{{steps.create-engagement.output.entityId}}"
|
|
810
|
+
start-date: "{{steps.handoff-time.output.result}}"
|
|
811
|
+
contract-notes: "Drafted automatically from the CRM handoff. Attach the signed document and set the term."
|
|
812
|
+
|
|
813
|
+
- id: link-contract
|
|
814
|
+
type: output
|
|
815
|
+
position: { x: 0, y: 1320 }
|
|
816
|
+
data:
|
|
817
|
+
label: Link contract → engagement
|
|
818
|
+
outputType: relation_create
|
|
819
|
+
config:
|
|
820
|
+
fromEntityId: "{{steps.create-contract.output.entityId}}"
|
|
821
|
+
toEntityId: "{{steps.create-engagement.output.entityId}}"
|
|
822
|
+
relationType: contract_for_engagement
|
|
823
|
+
edges:
|
|
824
|
+
- { id: h-trigger-list, source: trigger, target: list-clients }
|
|
825
|
+
# `handoff-time` is a `compute` node, and `compute` is NOT a per-item
|
|
826
|
+
# loop-body type — the executor's body walk STOPS at it. Sitting inside
|
|
827
|
+
# the loop chain it severed the body, orphaning create-engagement /
|
|
828
|
+
# link-engagement / create-contract / link-contract into the main pass
|
|
829
|
+
# where `{{loop.item.id}}` resolved to undefined (empty-id entities and
|
|
830
|
+
# relations). It is a RUN-level timestamp, identical for every item, so
|
|
831
|
+
# it belongs before the loop; `{{steps.handoff-time.output.result}}`
|
|
832
|
+
# still resolves from inside the body because step outputs are global.
|
|
833
|
+
- { id: h-list-time, source: list-clients, target: handoff-time }
|
|
834
|
+
- { id: h-time-loop, source: handoff-time, target: handoff-loop }
|
|
835
|
+
- { id: h-loop-facets, source: handoff-loop, target: read-client-facets }
|
|
836
|
+
- { id: h-facets-status, source: read-client-facets, target: client-handoff-status }
|
|
837
|
+
- { id: h-status-gate, source: client-handoff-status, target: handoff-gate }
|
|
838
|
+
- { id: h-gate-existing, source: handoff-gate, target: existing-engagements }
|
|
839
|
+
- { id: h-existing-gate, source: existing-engagements, target: engagement-gate }
|
|
840
|
+
- { id: h-gate-engagement, source: engagement-gate, target: create-engagement }
|
|
841
|
+
- { id: h-engagement-link, source: create-engagement, target: link-engagement }
|
|
842
|
+
- { id: h-link-contract, source: link-engagement, target: create-contract }
|
|
843
|
+
- { id: h-contract-link, source: create-contract, target: link-contract }
|
package/src/outreach-comms.yaml
CHANGED
|
@@ -203,6 +203,202 @@ playbooks:
|
|
|
203
203
|
- entity.create
|
|
204
204
|
status: active
|
|
205
205
|
|
|
206
|
+
# ── LinkedIn conversation ingest (PURE-CONFIG automation — PROPOSAL) ─────────
|
|
207
|
+
# Pulls LinkedIn threads via Unipile and records each as an EXTERNAL channel +
|
|
208
|
+
# messages through the GENERIC `channel.ingest` verb. No provider code in the
|
|
209
|
+
# pod — this composes existing verbs (unipile_list_accounts /
|
|
210
|
+
# unipile_list_conversations / unipile_list_messages + channel.ingest).
|
|
211
|
+
#
|
|
212
|
+
# TRIGGER MODEL — ONE automation, both jobs (no high-freq polling):
|
|
213
|
+
# • RECONCILIATION: `triggerType: cron` at a LOW frequency (daily 06:00).
|
|
214
|
+
# • BACKFILL / on-demand: fire the SAME automation via
|
|
215
|
+
# `POST /automations/{id}/trigger` (MCP `synap_trigger_automation`), which
|
|
216
|
+
# "bypasses the automation's normal trigger config" and runs the flow once.
|
|
217
|
+
# channel.ingest is idempotent, so cron sweeps and manual backfills are safe to
|
|
218
|
+
# overlap and repeat.
|
|
219
|
+
#
|
|
220
|
+
# ⚠️ NEEDS-REVIEW — TWO verified engine constraints shape this flow; see the
|
|
221
|
+
# handoff report. (1) The automation engine has NO nested loops
|
|
222
|
+
# (LOOP_BODY_NODE_TYPES excludes `loop`/`sub_automation`), so the requested
|
|
223
|
+
# `loop conversations → loop messages → channel.ingest per message` graph is
|
|
224
|
+
# not expressible. This does ONE loop over conversations and hands each thread's
|
|
225
|
+
# whole `messages` array to channel.ingest in a single call. (2) A `transform`
|
|
226
|
+
# `map:` pipe can only project a single field/string per item, so it cannot
|
|
227
|
+
# normalize messages into `{text, sentAt, participant, idempotencySeed}` objects
|
|
228
|
+
# in-flow. => channel.ingest must accept a BATCH `messages: [...]` of raw
|
|
229
|
+
# provider rows and do the per-message normalization + idempotent dedup
|
|
230
|
+
# SERVER-SIDE. That is a contract change vs the single-message signature.
|
|
231
|
+
# Kept `status: draft` until that batch shape ships; then activate.
|
|
232
|
+
automations:
|
|
233
|
+
- key: linkedin-conversation-ingest
|
|
234
|
+
name: LinkedIn conversation ingest
|
|
235
|
+
description: >-
|
|
236
|
+
Pull LinkedIn conversations via Unipile and record each thread as an
|
|
237
|
+
external channel + its messages through channel.ingest (idempotent, so
|
|
238
|
+
re-runs are safe). Cron = low-frequency reconciliation; manual trigger =
|
|
239
|
+
on-demand backfill.
|
|
240
|
+
triggerType: cron
|
|
241
|
+
triggerConfig:
|
|
242
|
+
expression: "0 6 * * *"
|
|
243
|
+
status: draft
|
|
244
|
+
flow:
|
|
245
|
+
nodes:
|
|
246
|
+
- id: trigger
|
|
247
|
+
type: trigger
|
|
248
|
+
position: { x: 0, y: 0 }
|
|
249
|
+
data:
|
|
250
|
+
label: Daily 06:00 reconciliation (also fired manually for backfill)
|
|
251
|
+
triggerType: cron
|
|
252
|
+
config:
|
|
253
|
+
expression: "0 6 * * *"
|
|
254
|
+
|
|
255
|
+
# Resolve the connected account AT RUNTIME so the cron sweep needs no
|
|
256
|
+
# payload. A multi-account / multi-provider pod would still need a
|
|
257
|
+
# run-param accountId to sweep MORE than the first LinkedIn account — a
|
|
258
|
+
# per-account sweep would itself need the nested loop this engine lacks
|
|
259
|
+
# (secondary follow-up, noted in the report).
|
|
260
|
+
- id: list-accounts
|
|
261
|
+
type: capability
|
|
262
|
+
position: { x: 0, y: 120 }
|
|
263
|
+
data:
|
|
264
|
+
label: Resolve the connected Unipile account
|
|
265
|
+
verbId: unipile_list_accounts
|
|
266
|
+
|
|
267
|
+
# F5 — PROVIDER FILTER. `unipile_list_accounts` returns RAW Unipile v2
|
|
268
|
+
# account rows and a pod can connect several providers, so a non-LinkedIn
|
|
269
|
+
# account (WhatsApp / Telegram / Gmail) can sit at index 0. Taking a blind
|
|
270
|
+
# `accounts.0` would ingest that provider's threads AS linkedin. This
|
|
271
|
+
# `transform` node's array `filter:` pipe keeps ONLY LinkedIn accounts;
|
|
272
|
+
# every step below takes `.0` of the FILTERED list.
|
|
273
|
+
# Field: the predicate matches the RAW Unipile v2 account shape
|
|
274
|
+
# `type: 'LINKEDIN' | 'GMAIL' | 'WHATSAPP' | …` (verified in
|
|
275
|
+
# UnipileConnector's UnipileAccountSchema, NOT the capability's loose
|
|
276
|
+
# "provider" prose). evaluateCondition does an EXACT, case-sensitive
|
|
277
|
+
# `==`, hence the uppercase literal.
|
|
278
|
+
# Failure mode: if no LinkedIn account is connected (or the raw field
|
|
279
|
+
# shape ever drifts) the filtered list is empty → the accountId below
|
|
280
|
+
# resolves to "" → the run FAILS VISIBLY at list-conversations. It never
|
|
281
|
+
# silently ingests the wrong provider — that is the point of F5.
|
|
282
|
+
# Multi-LinkedIn-account: still takes the first; a run-param accountId
|
|
283
|
+
# is the multi-account follow-up (noted in the report).
|
|
284
|
+
- id: filter-linkedin-account
|
|
285
|
+
type: transform
|
|
286
|
+
position: { x: 0, y: 180 }
|
|
287
|
+
data:
|
|
288
|
+
label: Keep only LinkedIn Unipile accounts
|
|
289
|
+
expression: "{{steps.list-accounts.output.accounts}} | filter:item.type == 'LINKEDIN'"
|
|
290
|
+
|
|
291
|
+
- id: list-conversations
|
|
292
|
+
type: capability
|
|
293
|
+
position: { x: 0, y: 240 }
|
|
294
|
+
data:
|
|
295
|
+
label: List LinkedIn conversations for the account
|
|
296
|
+
verbId: unipile_list_conversations
|
|
297
|
+
inputMapping:
|
|
298
|
+
accountId: "{{steps.filter-linkedin-account.output.result.0.id}}"
|
|
299
|
+
|
|
300
|
+
# NOTE: the automation engine caps loops at MAX_LOOP_ITERATIONS=100 per run
|
|
301
|
+
# (logs a warning, then truncates). So one cron pass reconciles the 100
|
|
302
|
+
# most-recent conversations; accounts with >100 threads need either a
|
|
303
|
+
# newest-first list (so the active threads are always covered) or a paged
|
|
304
|
+
# sweep (blocked today — would need the nested loops the engine lacks).
|
|
305
|
+
# Fine for the current dogfood scale; revisit if a user exceeds ~100 threads.
|
|
306
|
+
- id: conversations-loop
|
|
307
|
+
type: loop
|
|
308
|
+
position: { x: 0, y: 360 }
|
|
309
|
+
data:
|
|
310
|
+
label: For each conversation
|
|
311
|
+
iteratorExpression: steps.list-conversations.output.conversations
|
|
312
|
+
itemVariable: conversation
|
|
313
|
+
|
|
314
|
+
# Loop body (owned by conversations-loop): list this thread's messages,
|
|
315
|
+
# then ingest. `{{loop.item.id}}` is the chat/thread id for THIS
|
|
316
|
+
# iteration; `steps.list-messages` is re-bound per iteration.
|
|
317
|
+
- id: list-messages
|
|
318
|
+
type: capability
|
|
319
|
+
position: { x: 0, y: 480 }
|
|
320
|
+
data:
|
|
321
|
+
label: List this thread's messages
|
|
322
|
+
verbId: unipile_list_messages
|
|
323
|
+
inputMapping:
|
|
324
|
+
threadId: "{{loop.item.id}}"
|
|
325
|
+
accountId: "{{steps.filter-linkedin-account.output.result.0.id}}"
|
|
326
|
+
|
|
327
|
+
# GENERIC ingest — records the external channel for this thread and its
|
|
328
|
+
# messages, idempotently (dedup on a per-message idempotency seed derived
|
|
329
|
+
# SERVER-SIDE, e.g. `${externalId}:${sentAt}:${body}`). See the ⚠️ note:
|
|
330
|
+
# `messages` is a BATCH of raw provider rows (pending channel.ingest's
|
|
331
|
+
# batch contract).
|
|
332
|
+
- id: ingest
|
|
333
|
+
type: capability
|
|
334
|
+
position: { x: 0, y: 600 }
|
|
335
|
+
data:
|
|
336
|
+
label: Ingest thread + messages (idempotent, generic)
|
|
337
|
+
verbId: channel.ingest
|
|
338
|
+
inputMapping:
|
|
339
|
+
provider: linkedin
|
|
340
|
+
externalId: "{{loop.item.id}}"
|
|
341
|
+
title: "{{loop.item.name}}"
|
|
342
|
+
accountExternalId: "{{steps.filter-linkedin-account.output.result.0.id}}"
|
|
343
|
+
# F1 — TARGET THE CRM WORKSPACE. Without this, channel.ingest lands
|
|
344
|
+
# in the automation's OWN workspace, but the dashboard inbox reads
|
|
345
|
+
# "The arch CRM". Single-tenant target = the inbox/CRM workspace.
|
|
346
|
+
# TODO(multi-tenant): make this an install/run param instead of a
|
|
347
|
+
# literal (the flow schema has no template-var/param mechanism for a
|
|
348
|
+
# capability inputMapping today, so a documented literal is the
|
|
349
|
+
# single-tenant path — see the report).
|
|
350
|
+
workspaceId: "499328b7-fd35-4143-b1bc-67b9e45d9b4b"
|
|
351
|
+
# F2 — SUPPRESS THE BACKFILL STORM. This is a poll / reconciliation
|
|
352
|
+
# of HISTORICAL messages, so it must NOT emit
|
|
353
|
+
# `external_message.received` per message (channel resolve + dedup
|
|
354
|
+
# insert still run). Without this, one backfill of N threads fans
|
|
355
|
+
# out through the webhook + automation-trigger reactors N×messages.
|
|
356
|
+
suppressSideEffects: true
|
|
357
|
+
# BATCH mode: hand the whole thread's messages[] to the generic verb
|
|
358
|
+
# in ONE call (the engine has no nested loops). channel.ingest stays
|
|
359
|
+
# provider-agnostic — messageMap tells it (in CONFIG) where each field
|
|
360
|
+
# lives inside a raw Unipile v2 message row. Verified live 2026-07-27:
|
|
361
|
+
# a message = { id, text, timestamp (ISO), sender_id, is_sender, ... }.
|
|
362
|
+
messages: "{{steps.list-messages.output.messages}}"
|
|
363
|
+
messageMap:
|
|
364
|
+
text: text
|
|
365
|
+
id: id
|
|
366
|
+
sentAt: timestamp
|
|
367
|
+
# F3 — DIRECTION. `is_sender` (dot-path to a boolean per row) marks
|
|
368
|
+
# the operator's OWN sent messages as OUTBOUND so the inbox renders
|
|
369
|
+
# them right-aligned, instead of everything looking inbound.
|
|
370
|
+
isOutbound: is_sender
|
|
371
|
+
# F6 — participantExternalId DROPPED on purpose. link-at-birth
|
|
372
|
+
# binds the channel to the participant of the first-processed
|
|
373
|
+
# (newest) message, which — when that message is the account
|
|
374
|
+
# OWNER's own reply — mis-binds the whole thread to yourself. The
|
|
375
|
+
# channel `title` already carries the conversation (contact) name,
|
|
376
|
+
# so dropping the per-message sender id lets link-at-birth avoid
|
|
377
|
+
# the mis-bind.
|
|
378
|
+
edges:
|
|
379
|
+
- { id: e-trigger-accounts, source: trigger, target: list-accounts }
|
|
380
|
+
- {
|
|
381
|
+
id: e-accounts-filter,
|
|
382
|
+
source: list-accounts,
|
|
383
|
+
target: filter-linkedin-account,
|
|
384
|
+
}
|
|
385
|
+
- {
|
|
386
|
+
id: e-filter-convos,
|
|
387
|
+
source: filter-linkedin-account,
|
|
388
|
+
target: list-conversations,
|
|
389
|
+
}
|
|
390
|
+
- {
|
|
391
|
+
id: e-convos-loop,
|
|
392
|
+
source: list-conversations,
|
|
393
|
+
target: conversations-loop,
|
|
394
|
+
}
|
|
395
|
+
- {
|
|
396
|
+
id: e-loop-messages,
|
|
397
|
+
source: conversations-loop,
|
|
398
|
+
target: list-messages,
|
|
399
|
+
}
|
|
400
|
+
- { id: e-messages-ingest, source: list-messages, target: ingest }
|
|
401
|
+
|
|
206
402
|
# LinkedIn send/search via Unipile, email send via Gmail (nango-google). The
|
|
207
403
|
# user connects each once on install; a missing key fails gracefully per-skill.
|
|
208
404
|
integrations:
|
|
@@ -268,7 +268,12 @@ profiles:
|
|
|
268
268
|
icon: user
|
|
269
269
|
color: "#0891B2"
|
|
270
270
|
description: "Team member or stakeholder"
|
|
271
|
-
|
|
271
|
+
# Pod-wide identity: `person` is ONE pod-wide SYSTEM kind. `entityScope: pod`
|
|
272
|
+
# lands the ROWS with `workspace_id = NULL` (visible from every workspace);
|
|
273
|
+
# `scope` omitted (no claim) so the applier reuses the system row and these
|
|
274
|
+
# project-management props land as this workspace's OVERLAY.
|
|
275
|
+
# (Was `scope: WORKSPACE`, which contradicted the pod-wide model.)
|
|
276
|
+
entityScope: pod
|
|
272
277
|
properties:
|
|
273
278
|
- slug: person-role
|
|
274
279
|
label: Role
|