@synap-core/workspace-templates 0.9.4 → 0.11.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.
@@ -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
- scope: WORKSPACE
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