voyageai-cli 1.28.0 → 1.29.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.
@@ -18,9 +18,13 @@ const VAI_TOOLS = new Set([
18
18
  'ingest', 'collections', 'models', 'explain', 'estimate',
19
19
  ]);
20
20
 
21
- const CONTROL_FLOW_TOOLS = new Set(['merge', 'filter', 'transform', 'generate']);
21
+ const CONTROL_FLOW_TOOLS = new Set(['merge', 'filter', 'transform', 'generate', 'conditional', 'loop', 'template']);
22
22
 
23
- const ALL_TOOLS = new Set([...VAI_TOOLS, ...CONTROL_FLOW_TOOLS]);
23
+ const PROCESSING_TOOLS = new Set(['chunk', 'aggregate']);
24
+
25
+ const INTEGRATION_TOOLS = new Set(['http']);
26
+
27
+ const ALL_TOOLS = new Set([...VAI_TOOLS, ...CONTROL_FLOW_TOOLS, ...PROCESSING_TOOLS, ...INTEGRATION_TOOLS]);
24
28
 
25
29
  // ════════════════════════════════════════════════════════════════════
26
30
  // Validation
@@ -52,8 +56,8 @@ function validateWorkflow(definition) {
52
56
  // Validate inputs schema
53
57
  if (definition.inputs) {
54
58
  for (const [key, schema] of Object.entries(definition.inputs)) {
55
- if (schema.type && !['string', 'number', 'boolean'].includes(schema.type)) {
56
- errors.push(`Input "${key}" has invalid type "${schema.type}" (must be string, number, or boolean)`);
59
+ if (schema.type && !['string', 'number', 'boolean', 'array'].includes(schema.type)) {
60
+ errors.push(`Input "${key}" has invalid type "${schema.type}" (must be string, number, boolean, or array)`);
57
61
  }
58
62
  }
59
63
  }
@@ -94,10 +98,22 @@ function validateWorkflow(definition) {
94
98
  // Check template references point to known step IDs or reserved prefixes
95
99
  // "item" and "index" are injected by forEach at runtime
96
100
  const forEachVars = step.forEach ? new Set(['item', 'index']) : new Set();
101
+ // For loop nodes, the "as" variable and inline step refs are scoped
102
+ const loopVars = new Set();
103
+ if (step.tool === 'loop' && step.inputs) {
104
+ if (step.inputs.as) loopVars.add(step.inputs.as);
105
+ loopVars.add('item');
106
+ loopVars.add('index');
107
+ }
97
108
  if (step.inputs) {
98
- const deps = extractDependencies(step.inputs);
109
+ // For loop nodes, only check dependencies on top-level inputs (items, as, maxIterations)
110
+ // not on the inline step's inputs which may reference the loop variable
111
+ const inputsToCheck = step.tool === 'loop'
112
+ ? { items: step.inputs.items }
113
+ : step.inputs;
114
+ const deps = extractDependencies(inputsToCheck);
99
115
  for (const dep of deps) {
100
- if (!forEachVars.has(dep) && !stepIds.has(dep) && !definition.steps.some(s => s.id === dep)) {
116
+ if (!forEachVars.has(dep) && !loopVars.has(dep) && !stepIds.has(dep) && !definition.steps.some(s => s.id === dep)) {
101
117
  errors.push(`${stepPrefix}: references unknown step "${dep}"`);
102
118
  }
103
119
  }
@@ -119,6 +135,44 @@ function validateWorkflow(definition) {
119
135
  errors.push(`Duplicate step id: "${id}"`);
120
136
  }
121
137
 
138
+ // Validate conditional branch references
139
+ for (const step of definition.steps) {
140
+ if (step.tool === 'conditional' && step.inputs) {
141
+ const branches = ['then', 'else'];
142
+ for (const branch of branches) {
143
+ const refs = step.inputs[branch];
144
+ if (refs && Array.isArray(refs)) {
145
+ for (const ref of refs) {
146
+ if (!stepIds.has(ref)) {
147
+ errors.push(`Step "${step.id}": conditional ${branch} references unknown step "${ref}"`);
148
+ }
149
+ }
150
+ }
151
+ }
152
+ if (!step.inputs.condition) {
153
+ errors.push(`Step "${step.id}": conditional must have a "condition" input`);
154
+ }
155
+ if (!step.inputs.then || !Array.isArray(step.inputs.then)) {
156
+ errors.push(`Step "${step.id}": conditional must have a "then" array`);
157
+ }
158
+ }
159
+
160
+ // Validate loop inline step
161
+ if (step.tool === 'loop' && step.inputs) {
162
+ if (!step.inputs.items) {
163
+ errors.push(`Step "${step.id}": loop must have an "items" input`);
164
+ }
165
+ if (!step.inputs.as || typeof step.inputs.as !== 'string') {
166
+ errors.push(`Step "${step.id}": loop must have a string "as" input`);
167
+ }
168
+ if (!step.inputs.step || typeof step.inputs.step !== 'object') {
169
+ errors.push(`Step "${step.id}": loop must have a "step" object`);
170
+ } else if (step.inputs.step.tool && !ALL_TOOLS.has(step.inputs.step.tool)) {
171
+ errors.push(`Step "${step.id}": loop sub-step has unknown tool "${step.inputs.step.tool}"`);
172
+ }
173
+ }
174
+ }
175
+
122
176
  // Check for circular dependencies
123
177
  const cycleErrors = detectCycles(definition.steps);
124
178
  errors.push(...cycleErrors);
@@ -197,6 +251,18 @@ function detectCycles(steps) {
197
251
  function buildDependencyGraph(steps) {
198
252
  const graph = new Map();
199
253
 
254
+ // First pass: build index of conditional branches
255
+ // Steps referenced in then/else of a conditional depend on that conditional
256
+ const conditionalDeps = new Map(); // stepId -> conditionalStepId
257
+ for (const step of steps) {
258
+ if (step.tool === 'conditional' && step.inputs) {
259
+ const branches = [...(step.inputs.then || []), ...(step.inputs.else || [])];
260
+ for (const ref of branches) {
261
+ conditionalDeps.set(ref, step.id);
262
+ }
263
+ }
264
+ }
265
+
200
266
  for (const step of steps) {
201
267
  const deps = extractDependencies(step.inputs || {});
202
268
  if (step.condition) {
@@ -207,6 +273,10 @@ function buildDependencyGraph(steps) {
207
273
  const forDeps = extractDependencies(step.forEach);
208
274
  for (const d of forDeps) deps.add(d);
209
275
  }
276
+ // If this step is referenced by a conditional, it depends on that conditional
277
+ if (conditionalDeps.has(step.id)) {
278
+ deps.add(conditionalDeps.get(step.id));
279
+ }
210
280
  graph.set(step.id, deps);
211
281
  }
212
282
 
@@ -554,6 +624,301 @@ function executeTransform(inputs) {
554
624
  return { results, resultCount: results.length };
555
625
  }
556
626
 
627
+ /**
628
+ * Execute a conditional step: evaluate condition and determine branch.
629
+ *
630
+ * NOTE: The actual branch enforcement (skipping steps) is handled by
631
+ * the main execution loop, not here. This just evaluates and returns
632
+ * which branch was taken.
633
+ *
634
+ * @param {object} inputs - { condition: string, then: string[], else?: string[] }
635
+ * @param {object} context - workflow context
636
+ * @returns {{ conditionResult: boolean, branchTaken: string, enabledSteps: string[], skippedSteps: string[] }}
637
+ */
638
+ function executeConditional(inputs, context) {
639
+ const { condition } = inputs;
640
+ const thenSteps = inputs.then || [];
641
+ const elseSteps = inputs.else || [];
642
+
643
+ if (!condition && condition !== false && condition !== 0) {
644
+ throw new Error('conditional: "condition" input is required');
645
+ }
646
+
647
+ // Condition may already be resolved by template engine to a boolean
648
+ let result;
649
+ if (typeof condition === 'boolean') {
650
+ result = condition;
651
+ } else if (typeof condition === 'string') {
652
+ result = evaluateCondition(condition, context);
653
+ } else {
654
+ result = Boolean(condition);
655
+ }
656
+
657
+ const taken = result ? 'then' : 'else';
658
+ const enabled = result ? thenSteps : elseSteps;
659
+ const skipped = result ? elseSteps : thenSteps;
660
+
661
+ return {
662
+ conditionResult: result,
663
+ branchTaken: taken,
664
+ enabledSteps: enabled,
665
+ skippedSteps: skipped,
666
+ };
667
+ }
668
+
669
+ /**
670
+ * Execute a template step: compose text from template.
671
+ *
672
+ * @param {object} inputs - { text: string }
673
+ * @returns {{ text: string, charCount: number, referencedSteps: string[] }}
674
+ */
675
+ function executeTemplate(inputs) {
676
+ const { text } = inputs;
677
+
678
+ if (text === undefined || text === null) {
679
+ throw new Error('template: "text" input is required');
680
+ }
681
+
682
+ const textStr = String(text);
683
+ // Extract referenced step IDs from the original template (before resolution)
684
+ // Since inputs are already resolved by this point, we just return the composed text
685
+ return {
686
+ text: textStr,
687
+ charCount: textStr.length,
688
+ };
689
+ }
690
+
691
+ /**
692
+ * Execute a loop step: iterate over an array, executing a sub-step per item.
693
+ *
694
+ * @param {object} inputs - { items, as, step, maxIterations? }
695
+ * @param {object} defaults - workflow defaults
696
+ * @param {object} context - workflow context
697
+ * @returns {Promise<{ iterations: number, results: any[], errors: any[] }>}
698
+ */
699
+ async function executeLoop(inputs, defaults, context) {
700
+ const { items, as, step: subStepDef, maxIterations = 100 } = inputs;
701
+
702
+ if (!Array.isArray(items)) {
703
+ throw new Error('loop: "items" must resolve to an array');
704
+ }
705
+ if (!as || typeof as !== 'string') {
706
+ throw new Error('loop: "as" must be a string variable name');
707
+ }
708
+ if (!subStepDef || typeof subStepDef !== 'object') {
709
+ throw new Error('loop: "step" must be a step definition object');
710
+ }
711
+
712
+ const results = [];
713
+ const errors = [];
714
+
715
+ const limit = Math.min(items.length, maxIterations);
716
+
717
+ for (let i = 0; i < limit; i++) {
718
+ const item = items[i];
719
+ // Build scoped context with loop variable
720
+ const scopedContext = { ...context, [as]: item, _loopIndex: i };
721
+
722
+ try {
723
+ // Resolve the sub-step inputs in the scoped context
724
+ const resolvedInputs = resolveTemplate(subStepDef.inputs || {}, scopedContext);
725
+ // Create a temporary step object for the dispatcher
726
+ const tempStep = { id: `_loop_${i}`, tool: subStepDef.tool, inputs: subStepDef.inputs };
727
+ const output = await executeStep(tempStep, resolvedInputs, defaults, scopedContext);
728
+ results.push(output);
729
+ } catch (err) {
730
+ errors.push({ index: i, error: err.message });
731
+ // If the parent loop has continueOnError, we keep going (handled by caller)
732
+ // For now, loop always continues and collects errors
733
+ }
734
+ }
735
+
736
+ if (items.length > maxIterations) {
737
+ errors.push({ index: maxIterations, error: `Loop truncated at maxIterations (${maxIterations})` });
738
+ }
739
+
740
+ return {
741
+ iterations: results.length,
742
+ results,
743
+ errors,
744
+ };
745
+ }
746
+
747
+ /**
748
+ * Execute a chunk step: split text using vai's chunking strategies.
749
+ *
750
+ * @param {object} inputs - { text, strategy?, size?, overlap?, source? }
751
+ * @returns {{ chunks: object[], totalChunks: number, strategy: string, avgChunkSize: number }}
752
+ */
753
+ function executeChunk(inputs) {
754
+ const { chunk: doChunk } = require('./chunker');
755
+
756
+ const { text, strategy = 'recursive', size = 512, overlap = 50, source } = inputs;
757
+
758
+ if (!text && text !== '') {
759
+ throw new Error('chunk: "text" input is required');
760
+ }
761
+
762
+ const chunkTexts = doChunk(text, { strategy, size, overlap });
763
+
764
+ const chunks = chunkTexts.map((content, index) => {
765
+ const obj = {
766
+ index,
767
+ content,
768
+ charCount: content.length,
769
+ };
770
+ if (source) obj.source = source;
771
+ obj.metadata = { strategy };
772
+ // For markdown strategy, try to extract heading
773
+ if (strategy === 'markdown') {
774
+ const headingMatch = content.match(/^#+\s+(.+)/m);
775
+ if (headingMatch) obj.metadata.heading = headingMatch[1];
776
+ }
777
+ return obj;
778
+ });
779
+
780
+ const totalChars = chunks.reduce((sum, c) => sum + c.charCount, 0);
781
+
782
+ return {
783
+ chunks,
784
+ totalChunks: chunks.length,
785
+ strategy,
786
+ avgChunkSize: chunks.length > 0 ? Math.round(totalChars / chunks.length) : 0,
787
+ };
788
+ }
789
+
790
+ /**
791
+ * Execute an HTTP request step.
792
+ *
793
+ * @param {object} inputs - { url, method?, headers?, body?, timeout?, responseType?, followRedirects? }
794
+ * @returns {Promise<{ status: number, statusText: string, headers: object, body: any, durationMs: number }>}
795
+ */
796
+ async function executeHttp(inputs) {
797
+ const { url, method = 'GET', headers = {}, body, timeout = 30000, responseType = 'json', followRedirects = false } = inputs;
798
+
799
+ if (!url || typeof url !== 'string') {
800
+ throw new Error('http: "url" input is required');
801
+ }
802
+
803
+ // URL allowlisting check
804
+ try {
805
+ const { loadProject } = require('./project');
806
+ const { config: proj } = loadProject();
807
+ if (proj && proj.allowedHosts && Array.isArray(proj.allowedHosts)) {
808
+ const parsed = new URL(url);
809
+ if (!proj.allowedHosts.includes(parsed.hostname)) {
810
+ throw new Error(`http: host "${parsed.hostname}" is not in allowedHosts. Allowed: ${proj.allowedHosts.join(', ')}`);
811
+ }
812
+ }
813
+ } catch (e) {
814
+ if (e.message.includes('allowedHosts')) throw e;
815
+ // If project config can't be loaded, allow all hosts
816
+ }
817
+
818
+ const startTime = Date.now();
819
+
820
+ // Build fetch options
821
+ const fetchOpts = {
822
+ method: method.toUpperCase(),
823
+ headers: { ...headers },
824
+ signal: AbortSignal.timeout(timeout),
825
+ redirect: followRedirects ? 'follow' : 'manual',
826
+ };
827
+
828
+ if (body && ['POST', 'PUT', 'PATCH'].includes(fetchOpts.method)) {
829
+ if (typeof body === 'object') {
830
+ fetchOpts.body = JSON.stringify(body);
831
+ if (!fetchOpts.headers['Content-Type'] && !fetchOpts.headers['content-type']) {
832
+ fetchOpts.headers['Content-Type'] = 'application/json';
833
+ }
834
+ } else {
835
+ fetchOpts.body = String(body);
836
+ }
837
+ }
838
+
839
+ const response = await fetch(url, fetchOpts);
840
+ const durationMs = Date.now() - startTime;
841
+
842
+ // Response size limit: 5MB
843
+ const MAX_RESPONSE_SIZE = 5 * 1024 * 1024;
844
+ const responseText = await response.text();
845
+ const truncated = responseText.length > MAX_RESPONSE_SIZE;
846
+ const rawBody = truncated ? responseText.slice(0, MAX_RESPONSE_SIZE) : responseText;
847
+
848
+ // Parse body
849
+ let parsedBody;
850
+ if (responseType === 'json') {
851
+ try {
852
+ parsedBody = JSON.parse(rawBody);
853
+ } catch {
854
+ parsedBody = rawBody; // Fall back to text
855
+ }
856
+ } else {
857
+ parsedBody = rawBody;
858
+ }
859
+
860
+ // Collect response headers
861
+ const respHeaders = {};
862
+ response.headers.forEach((value, key) => {
863
+ respHeaders[key] = value;
864
+ });
865
+
866
+ return {
867
+ status: response.status,
868
+ statusText: response.statusText,
869
+ headers: respHeaders,
870
+ body: parsedBody,
871
+ durationMs,
872
+ ...(truncated && { warning: 'Response truncated at 5MB' }),
873
+ };
874
+ }
875
+
876
+ /**
877
+ * Execute a MongoDB aggregation pipeline step.
878
+ *
879
+ * @param {object} inputs - { db?, collection?, pipeline, allowWrites? }
880
+ * @param {object} defaults - workflow defaults
881
+ * @returns {Promise<{ results: any[], count: number, durationMs: number }>}
882
+ */
883
+ async function executeAggregate(inputs, defaults) {
884
+ const { getMongoCollection } = require('./mongo');
885
+ const { loadProject } = require('./project');
886
+ const { config: proj } = loadProject();
887
+
888
+ const db = inputs.db || defaults.db || proj.db;
889
+ const collection = inputs.collection || defaults.collection || proj.collection;
890
+ const pipeline = inputs.pipeline;
891
+ const allowWrites = inputs.allowWrites || false;
892
+
893
+ if (!db) throw new Error('aggregate: database not specified');
894
+ if (!collection) throw new Error('aggregate: collection not specified');
895
+ if (!Array.isArray(pipeline)) throw new Error('aggregate: "pipeline" must be an array');
896
+ if (pipeline.length > 20) throw new Error('aggregate: pipeline limited to 20 stages');
897
+
898
+ // Block write stages unless explicitly allowed
899
+ if (!allowWrites) {
900
+ for (const stage of pipeline) {
901
+ const stageKey = Object.keys(stage)[0];
902
+ if (stageKey === '$out' || stageKey === '$merge') {
903
+ throw new Error(`aggregate: "${stageKey}" stage is not allowed without allowWrites: true`);
904
+ }
905
+ }
906
+ }
907
+
908
+ const startTime = Date.now();
909
+ const { client, collection: col } = await getMongoCollection(db, collection);
910
+ try {
911
+ const results = await col.aggregate(pipeline).toArray();
912
+ return {
913
+ results,
914
+ count: results.length,
915
+ durationMs: Date.now() - startTime,
916
+ };
917
+ } finally {
918
+ await client.close();
919
+ }
920
+ }
921
+
557
922
  // ════════════════════════════════════════════════════════════════════
558
923
  // VAI Tool Executors
559
924
  // ════════════════════════════════════════════════════════════════════
@@ -938,6 +1303,18 @@ async function executeStep(step, resolvedInputs, defaults, context) {
938
1303
  return executeTransform(resolvedInputs);
939
1304
  case 'generate':
940
1305
  return executeGenerate(resolvedInputs);
1306
+ case 'conditional':
1307
+ return executeConditional(resolvedInputs, context);
1308
+ case 'template':
1309
+ return executeTemplate(resolvedInputs);
1310
+ case 'loop':
1311
+ return executeLoop(resolvedInputs, defaults, context);
1312
+ case 'chunk':
1313
+ return executeChunk(resolvedInputs);
1314
+ case 'http':
1315
+ return executeHttp(resolvedInputs);
1316
+ case 'aggregate':
1317
+ return executeAggregate(resolvedInputs, defaults);
941
1318
 
942
1319
  // VAI tools
943
1320
  case 'query':
@@ -1056,12 +1433,27 @@ async function executeWorkflow(definition, opts = {}) {
1056
1433
 
1057
1434
  // Execute layer by layer
1058
1435
  const stepResults = [];
1436
+ const skippedByConditional = new Set(); // Steps skipped by conditional branches
1059
1437
 
1060
1438
  for (const layer of layers) {
1061
1439
  const layerPromises = layer.map(async (stepId) => {
1062
1440
  const step = stepMap.get(stepId);
1063
1441
  const stepStart = Date.now();
1064
1442
 
1443
+ // Check if this step was skipped by a conditional branch
1444
+ if (skippedByConditional.has(stepId)) {
1445
+ if (opts.onStepSkip) opts.onStepSkip(stepId, 'conditional branch not taken');
1446
+ context[stepId] = { output: null, skipped: true };
1447
+ stepResults.push({
1448
+ id: stepId,
1449
+ tool: step.tool,
1450
+ skipped: true,
1451
+ reason: 'conditional branch not taken',
1452
+ durationMs: Date.now() - stepStart,
1453
+ });
1454
+ return;
1455
+ }
1456
+
1065
1457
  // Evaluate condition
1066
1458
  if (step.condition) {
1067
1459
  const conditionMet = evaluateCondition(step.condition, context);
@@ -1083,7 +1475,26 @@ async function executeWorkflow(definition, opts = {}) {
1083
1475
  try {
1084
1476
  let output;
1085
1477
 
1086
- if (step.forEach) {
1478
+ if (step.tool === 'conditional') {
1479
+ // Special handling: resolve then/else but pass raw condition to evaluator
1480
+ const rawCondition = step.inputs.condition;
1481
+ const resolvedInputs = {
1482
+ condition: rawCondition, // Keep raw for condition evaluator
1483
+ then: step.inputs.then || [],
1484
+ else: step.inputs.else || [],
1485
+ };
1486
+ output = await executeStep(step, resolvedInputs, defaults, context);
1487
+ } else if (step.tool === 'loop') {
1488
+ // Special handling: resolve items but pass raw step def for per-iteration resolution
1489
+ const resolvedItems = resolveTemplate(step.inputs.items, context);
1490
+ const resolvedInputs = {
1491
+ items: resolvedItems,
1492
+ as: step.inputs.as,
1493
+ step: step.inputs.step, // Raw — resolved per iteration inside executeLoop
1494
+ maxIterations: step.inputs.maxIterations,
1495
+ };
1496
+ output = await executeStep(step, resolvedInputs, defaults, context);
1497
+ } else if (step.forEach) {
1087
1498
  // Iterate over an array
1088
1499
  const iterArray = resolveTemplate(step.forEach, context);
1089
1500
  if (!Array.isArray(iterArray)) {
@@ -1104,6 +1515,13 @@ async function executeWorkflow(definition, opts = {}) {
1104
1515
  output = await executeStep(step, resolvedInputs, defaults, context);
1105
1516
  }
1106
1517
 
1518
+ // If this was a conditional node, mark skipped branch steps
1519
+ if (step.tool === 'conditional' && output.skippedSteps) {
1520
+ for (const skippedId of output.skippedSteps) {
1521
+ skippedByConditional.add(skippedId);
1522
+ }
1523
+ }
1524
+
1107
1525
  const durationMs = Date.now() - stepStart;
1108
1526
  context[stepId] = { output };
1109
1527
 
@@ -1333,6 +1751,12 @@ module.exports = {
1333
1751
  executeMerge,
1334
1752
  executeFilter,
1335
1753
  executeTransform,
1754
+ executeConditional,
1755
+ executeTemplate,
1756
+ executeLoop,
1757
+ executeChunk,
1758
+ executeHttp,
1759
+ executeAggregate,
1336
1760
 
1337
1761
  // Main execution
1338
1762
  executeStep,
@@ -1351,5 +1775,7 @@ module.exports = {
1351
1775
  // Constants
1352
1776
  VAI_TOOLS,
1353
1777
  CONTROL_FLOW_TOOLS,
1778
+ PROCESSING_TOOLS,
1779
+ INTEGRATION_TOOLS,
1354
1780
  ALL_TOOLS,
1355
1781
  };
@@ -0,0 +1,71 @@
1
+ # VAI Announcements
2
+
3
+ This file contains the announcements displayed on the VAI home page carousel.
4
+ Each announcement is separated by `---` and uses YAML frontmatter for metadata.
5
+
6
+ Format:
7
+ - `id`: Unique identifier (used to track dismissals)
8
+ - `badge`: Label shown on the card (e.g., "New", "Update", "New Model")
9
+ - `published`: Date published (YYYY-MM-DD)
10
+ - `expires`: Date to stop showing (YYYY-MM-DD)
11
+ - `cta_label`: Button text
12
+ - `cta_action`: Either "navigate" (internal tab) or "link" (external URL)
13
+ - `cta_target`: Tab name (e.g., "/benchmark") or full URL
14
+
15
+ The title is the first `## ` heading, and the description is the paragraph below it.
16
+
17
+ ---
18
+
19
+ id: ann-voyage-4
20
+ badge: New Model
21
+ published: 2026-02-14
22
+ expires: 2026-03-15
23
+ cta_label: Try It Now
24
+ cta_action: navigate
25
+ cta_target: /benchmark
26
+
27
+ ## Voyage AI 4 Large Now Available
28
+
29
+ The latest embedding model from Voyage AI is ready to benchmark in VAI. Experience improved accuracy and performance across all embedding tasks.
30
+
31
+ ---
32
+
33
+ id: ann-marketplace-workflows
34
+ badge: New
35
+ published: 2026-02-12
36
+ expires: 2026-03-01
37
+ cta_label: Explore Marketplace
38
+ cta_action: navigate
39
+ cta_target: /workflows
40
+
41
+ ## New Marketplace Workflows
42
+
43
+ HIPAA-compliant document search, legal contract analysis, and more workflows are now available in the VAI Marketplace.
44
+
45
+ ---
46
+
47
+ id: ann-csv-ingestion
48
+ badge: Update
49
+ published: 2026-02-10
50
+ expires: 2026-04-01
51
+ cta_label: Learn More
52
+ cta_action: link
53
+ cta_target: https://docs.vaicli.com/csv-import
54
+
55
+ ## VAI v1.3 Adds Bulk CSV Ingestion
56
+
57
+ Import large datasets efficiently with the new bulk CSV ingestion feature. Process thousands of documents in a single operation.
58
+
59
+ ---
60
+
61
+ id: ann-workflow-store
62
+ badge: New
63
+ published: 2026-02-13
64
+ expires: 2026-03-15
65
+ cta_label: Browse Store
66
+ cta_action: navigate
67
+ cta_target: /workflows
68
+
69
+ ## Workflow Store Now Live
70
+
71
+ Discover, install, and share VAI workflows with the new integrated Workflow Store. Browse featured picks, community contributions, and install with one click.
Binary file