diff --git a/scripts/DB/default_workflow.sql b/scripts/DB/default_workflow.sql
index 2cd908f85..36a11eb99 100644
--- a/scripts/DB/default_workflow.sql
+++ b/scripts/DB/default_workflow.sql
@@ -99,8 +99,8 @@ DO UPDATE SET
 	"Type" = excluded."Type",
   "ProductType" = excluded."ProductType";
 
-INSERT INTO "WorkflowDefinitions" ("Id", "Name", "Enabled", "Description", "WorkflowScheme", "WorkflowBusinessFlow", "StoreTypeId", "Type", "ProductType") VALUES
-(9, 'pwa_cloud', '1', 'SIL Default Workflow for Publishing PWA to Cloud', 'SIL_Default_AppBuilders_Pwa_Cloud', 'SIL_AppBuilders_Web_Flow', 3, 1, 3)
+INSERT INTO "WorkflowDefinitions" ("Id", "Name", "Enabled", "Description", "WorkflowScheme", "WorkflowBusinessFlow", "StoreTypeId", "Type", "Properties", "ProductType") VALUES
+(9, 'pwa_cloud', '1', 'SIL Default Workflow for Publishing PWA to Cloud', 'SIL_Default_AppBuilders_Pwa_Cloud', 'SIL_AppBuilders_Web_Flow', 3, 1, '{ "build:targets" : "pwa" }', 3)
 ON CONFLICT ("Id")
 DO UPDATE SET
 	"Name" = excluded."Name",
@@ -112,8 +112,8 @@ DO UPDATE SET
 	"Type" = excluded."Type",
   "ProductType" = excluded."ProductType";
 
-INSERT INTO "WorkflowDefinitions" ("Id", "Name", "Enabled", "Description", "WorkflowScheme", "WorkflowBusinessFlow", "StoreTypeId", "Type", "ProductType") VALUES
-(10,	'pwa_cloud_rebuild',	'1',	'SIL Default Workflow for Rebuilding PWA to Cloud',	'SIL_Default_AppBuilders_Pwa_Cloud_Rebuild',	'SIL_AppBuilders_Web_Flow',	3,	2,  3)
+INSERT INTO "WorkflowDefinitions" ("Id", "Name", "Enabled", "Description", "WorkflowScheme", "WorkflowBusinessFlow", "StoreTypeId", "Type", "Properties", "ProductType") VALUES
+(10,	'pwa_cloud_rebuild',	'1',	'SIL Default Workflow for Rebuilding PWA to Cloud',	'SIL_Default_AppBuilders_Pwa_Cloud_Rebuild',	'SIL_AppBuilders_Web_Flow',	3,	2, '{ "build:targets" : "pwa" }', 3)
 ON CONFLICT ("Id")
 DO UPDATE SET
 	"Name" = excluded."Name",
@@ -151,8 +151,8 @@ DO UPDATE SET
 	"Type" = excluded."Type",
   "ProductType" = excluded."ProductType";
 
-INSERT INTO "WorkflowDefinitions" ("Id", "Name", "Enabled", "Description", "WorkflowScheme", "WorkflowBusinessFlow", "StoreTypeId", "Type", "Properties", "ProductType") VALUES
-(13,	'asset_package',	'1',	'SIL Default Workflow for Publishing Asset Packages',	'SIL_NoAdmin_AppBuilders_Android_S3',	'SIL_AppBuilders_AssetPackage_Flow',	2,	1, '{ "build:targets" : "asset-package" }', 2)
+INSERT INTO "WorkflowDefinitions" ("Id", "Name", "Enabled", "Description", "WorkflowScheme", "WorkflowBusinessFlow", "StoreTypeId", "Type", "ProductType") VALUES
+(13,	'asset_package',	'1',	'SIL Default Workflow for Publishing Asset Packages',	'SIL_NoAdmin_AppBuilders_Android_S3',	'SIL_AppBuilders_AssetPackage_Flow',	2,	1, 2)
 ON CONFLICT ("Id")
 DO UPDATE SET
 	"Name" = excluded."Name",
@@ -165,8 +165,8 @@ DO UPDATE SET
 	"Properties" = excluded."Properties",
   "ProductType" = excluded."ProductType";
 		
-INSERT INTO "WorkflowDefinitions" ("Id", "Name", "Enabled", "Description", "WorkflowScheme", "WorkflowBusinessFlow", "StoreTypeId", "Type", "Properties", "ProductType") VALUES
-(14,	'asset_package_rebuild',	'1',	'SIL Default Workflow for Rebuilding Asset Packages',	'SIL_Default_AppBuilders_Android_S3_Rebuild',	'SIL_AppBuilders_AssetPackage_Flow',	2,	2, '{ "build:targets" : "asset-package" }', 3)
+INSERT INTO "WorkflowDefinitions" ("Id", "Name", "Enabled", "Description", "WorkflowScheme", "WorkflowBusinessFlow", "StoreTypeId", "Type", "ProductType") VALUES
+(14,	'asset_package_rebuild',	'1',	'SIL Default Workflow for Rebuilding Asset Packages',	'SIL_Default_AppBuilders_Android_S3_Rebuild',	'SIL_AppBuilders_AssetPackage_Flow',	2,	2, 3)
 ON CONFLICT ("Id")
 DO UPDATE SET
 	"Name" = excluded."Name",
diff --git a/source/SIL.AppBuilder.Portal/common/bullmq/queues.ts b/source/SIL.AppBuilder.Portal/common/bullmq/queues.ts
index 025d42d76..13b698173 100644
--- a/source/SIL.AppBuilder.Portal/common/bullmq/queues.ts
+++ b/source/SIL.AppBuilder.Portal/common/bullmq/queues.ts
@@ -2,12 +2,36 @@ import { Queue } from 'bullmq';
 import type { Job } from './types.js';
 import { QueueName } from './types.js';
 
+/** Queue for Product Builds */
+export const Builds = new Queue<Job>(QueueName.Builds, {
+  connection: {
+    host: process.env.NODE_ENV === 'development' ? 'localhost' : 'redis'
+  }
+});
 /** Queue for default recurring jobs such as the BuildEngine status check */
 export const DefaultRecurring = new Queue<Job>(QueueName.DefaultRecurring, {
   connection: {
     host: process.env.NODE_ENV === 'development' ? 'localhost' : 'redis'
   }
 });
+/** Queue for miscellaneous jobs such as Product and Project Creation */
+export const Miscellaneous = new Queue<Job>(QueueName.Miscellaneous, {
+  connection: {
+    host: process.env.NODE_ENV === 'development' ? 'localhost' : 'redis'
+  }
+});
+/** Queue for Product Publishing  */
+export const Publishing = new Queue<Job>(QueueName.Publishing, {
+  connection: {
+    host: process.env.NODE_ENV === 'development' ? 'localhost' : 'redis'
+  }
+});
+/** Queue for jobs that poll BuildEngine, such as checking the status of a build */
+export const RemotePolling = new Queue<Job>(QueueName.RemotePolling, {
+  connection: {
+    host: process.env.NODE_ENV === 'development' ? 'localhost' : 'redis'
+  }
+});
 /** Queue for operations on UserTasks */
 export const UserTasks = new Queue<Job>(QueueName.UserTasks, {
   connection: {
diff --git a/source/SIL.AppBuilder.Portal/common/bullmq/types.ts b/source/SIL.AppBuilder.Portal/common/bullmq/types.ts
index 0788c8af1..41e74caa4 100644
--- a/source/SIL.AppBuilder.Portal/common/bullmq/types.ts
+++ b/source/SIL.AppBuilder.Portal/common/bullmq/types.ts
@@ -1,4 +1,5 @@
 /* eslint-disable @typescript-eslint/no-namespace */
+import type { Channels } from '../build-engine-api/types.js';
 import { RoleId } from '../public/prisma.js';
 
 interface RetryOptions {
@@ -17,18 +18,97 @@ export const Retry5e5: RetryOptions = {
   }
 };
 
+interface RepeatOptions {
+  readonly repeat: {
+    readonly pattern: string;
+  };
+}
+/** Repeat a job every minute */
+export const RepeatEveryMinute: RepeatOptions = {
+  repeat: {
+    pattern: '*/1 * * * *' // every minute
+  }
+};
+
 export enum QueueName {
+  Builds = 'Builds',
   DefaultRecurring = 'Default Recurring',
+  Miscellaneous = 'Miscellaneous',
+  Publishing = 'Publishing',
+  RemotePolling = 'Remote Polling',
   UserTasks = 'User Tasks'
 }
 
 export enum JobType {
+  // Build Tasks
+  Build_Product = 'Build Product',
+  Build_Check = 'Check Product Build',
+  // Product Tasks
+  Product_Create = 'Create Product',
+  Product_Delete = 'Delete Product',
+  Product_GetVersionCode = 'Get VersionCode for Uploaded Product',
+  // Publishing Tasks
+  Publish_Product = 'Publish Product',
+  Publish_Check = 'Check Product Publish',
   // System Tasks
   System_CheckStatuses = 'Check System Statuses',
   // UserTasks
   UserTasks_Modify = 'Modify UserTasks'
 }
 
+export namespace Build {
+  export interface Product {
+    type: JobType.Build_Product;
+    productId: string;
+    defaultTargets: string;
+    environment: { [key: string]: string };
+  }
+  export interface Check {
+    type: JobType.Build_Check;
+    organizationId: number;
+    productId: string;
+    jobId: number;
+    buildId: number;
+    productBuildId: number;
+  }
+}
+
+export namespace Product {
+  export interface Create {
+    type: JobType.Product_Create;
+    productId: string;
+  }
+  export interface Delete {
+    type: JobType.Product_Delete;
+    organizationId: number;
+    workflowJobId: number;
+  }
+  export interface GetVersionCode {
+    type: JobType.Product_GetVersionCode;
+    productId: string;
+  }
+}
+
+export namespace Publish {
+  export interface Product {
+    type: JobType.Publish_Product;
+    productId: string;
+    defaultChannel: Channels;
+    defaultTargets: string;
+    environment: { [key: string]: string };
+  }
+
+  export interface Check {
+    type: JobType.Publish_Check;
+    organizationId: number;
+    productId: string;
+    jobId: number;
+    buildId: number;
+    releaseId: number;
+    publicationId: number;
+  }
+}
+
 export namespace System {
   export interface CheckStatuses {
     type: JobType.System_CheckStatuses;
@@ -75,6 +155,13 @@ export namespace UserTasks {
 export type Job = JobTypeMap[keyof JobTypeMap];
 
 export type JobTypeMap = {
+  [JobType.Build_Product]: Build.Product;
+  [JobType.Build_Check]: Build.Check;
+  [JobType.Product_Create]: Product.Create;
+  [JobType.Product_Delete]: Product.Delete;
+  [JobType.Product_GetVersionCode]: Product.GetVersionCode;
+  [JobType.Publish_Product]: Publish.Product;
+  [JobType.Publish_Check]: Publish.Check;
   [JobType.System_CheckStatuses]: System.CheckStatuses;
   [JobType.UserTasks_Modify]: UserTasks.Modify;
   // Add more mappings here as needed
diff --git a/source/SIL.AppBuilder.Portal/common/databaseProxy/Products.ts b/source/SIL.AppBuilder.Portal/common/databaseProxy/Products.ts
index ff74e2e57..ff8a3536f 100644
--- a/source/SIL.AppBuilder.Portal/common/databaseProxy/Products.ts
+++ b/source/SIL.AppBuilder.Portal/common/databaseProxy/Products.ts
@@ -1,5 +1,9 @@
 import type { Prisma } from '@prisma/client';
+import { Workflow } from 'sil.appbuilder.portal.common';
+import { BullMQ, Queues } from '../index.js';
 import prisma from '../prisma.js';
+import { WorkflowType } from '../public/prisma.js';
+import { delete as deleteInstance } from './WorkflowInstances.js';
 import type { RequirePrimitive } from './utility.js';
 
 export async function create(
@@ -9,8 +13,8 @@ export async function create(
     !(await validateProductBase(
       productData.ProjectId,
       productData.ProductDefinitionId,
-      productData.StoreId,
-      productData.StoreLanguageId
+      productData.StoreId ?? undefined,
+      productData.StoreLanguageId ?? undefined
     ))
   )
     return false;
@@ -21,6 +25,34 @@ export async function create(
     const res = await prisma.products.create({
       data: productData
     });
+
+    if (res) {
+      const flowDefinition = (
+        await prisma.productDefinitions.findUnique({
+          where: {
+            Id: productData.ProductDefinitionId
+          },
+          select: {
+            Workflow: {
+              select: {
+                Id: true,
+                Type: true,
+                ProductType: true,
+                WorkflowOptions: true
+              }
+            }
+          }
+        })
+      )?.Workflow;
+
+      if (flowDefinition?.Type === WorkflowType.Startup) {
+        await Workflow.create(res.Id, {
+          productType: flowDefinition.ProductType,
+          options: new Set(flowDefinition.WorkflowOptions)
+        });
+      }
+    }
+
     return res.Id;
   } catch (e) {
     return false;
@@ -62,14 +94,33 @@ export async function update(
   return true;
 }
 
-function deleteProduct(productId: string) {
+async function deleteProduct(productId: string) {
   // Delete all userTasks for this product, and delete the product
+  const product = await prisma.products.findUnique({
+    where: {
+      Id: productId
+    },
+    select: {
+      Project: {
+        select: {
+          Id: true,
+          OrganizationId: true
+        }
+      },
+      WorkflowJobId: true
+    }
+  });
+  await Queues.Miscellaneous.add(
+    `Delete Product #${productId} from BuildEngine`,
+    {
+      type: BullMQ.JobType.Product_Delete,
+      organizationId: product!.Project.OrganizationId,
+      workflowJobId: product!.WorkflowJobId
+    },
+    BullMQ.Retry5e5
+  );
   return prisma.$transaction([
-    prisma.workflowInstances.deleteMany({
-      where: {
-        ProductId: productId
-      }
-    }),
+    deleteInstance(productId),
     prisma.userTasks.deleteMany({
       where: {
         ProductId: productId
@@ -81,7 +132,6 @@ function deleteProduct(productId: string) {
       }
     })
   ]);
-  // TODO: delete from BuildEngine
 }
 export { deleteProduct as delete };
 
@@ -140,7 +190,7 @@ async function validateProductBase(
                       Id: true,
                       // StoreLanguage must be allowed by Store, if the StoreLanguage is defined
                       StoreLanguages:
-                        storeLanguageId === undefined || storeLanguageId === null
+                        storeLanguageId === undefined
                           ? undefined
                           : {
                             where: {
@@ -166,7 +216,6 @@ async function validateProductBase(
       }
     }
   });
-
   // 3. The store is allowed by the organization
   return (
     (project?.Organization.OrganizationStores.length ?? 0) > 0 &&
@@ -175,10 +224,11 @@ async function validateProductBase(
       project?.Organization.OrganizationStores[0].Store.StoreType.Id &&
     // 2. The project has a WorkflowProjectUrl
     // handled by query
-    // 4. The language is allowed by the store
-    (storeLanguageId ??
+    // 4. The language, if specified, is allowed by the store
+    ((storeLanguageId &&
       (project?.Organization.OrganizationStores[0].Store.StoreType.StoreLanguages.length ?? 0) >
-        0) &&
+        0) ||
+      storeLanguageId === undefined) &&
     // 5. The product type is allowed by the organization
     (project?.Organization.OrganizationProductDefinitions.length ?? 0) > 0
   );
diff --git a/source/SIL.AppBuilder.Portal/common/databaseProxy/WorkflowInstances.ts b/source/SIL.AppBuilder.Portal/common/databaseProxy/WorkflowInstances.ts
new file mode 100644
index 000000000..bfe4509d0
--- /dev/null
+++ b/source/SIL.AppBuilder.Portal/common/databaseProxy/WorkflowInstances.ts
@@ -0,0 +1,80 @@
+import type { Prisma, PrismaPromise } from '@prisma/client';
+import prisma from '../prisma.js';
+import { update as projectUpdate } from './Projects.js';
+import type { RequirePrimitive } from './utility.js';
+
+export async function upsert(instanceData: {
+  where: Prisma.WorkflowInstancesWhereUniqueInput;
+  create: RequirePrimitive<Prisma.WorkflowInstancesUncheckedCreateInput>;
+  update: RequirePrimitive<Prisma.WorkflowInstancesUncheckedUpdateInput>;
+}) {
+  const timestamp = new Date();
+  const res = await prisma.workflowInstances.upsert(instanceData);
+
+  if (res.DateCreated && res.DateCreated > timestamp) {
+    const product = await prisma.products.findUniqueOrThrow({
+      where: {
+        Id: instanceData.create.ProductId
+      },
+      select: {
+        ProjectId: true
+      }
+    });
+  
+    await projectUpdate(product.ProjectId, { DateActive: new Date() });
+  }
+  return res;
+}
+
+//@ts-expect-error this was complaining about it not returning a global Promise. PrismaPromise extends global Promise and is require by prisma.$transaction, which for some reason didn't like a function that otherwise returned a called function that does indeed return a PrismaPromise.
+async function deleteInstance(productId: string): PrismaPromise<unknown> {
+  const product = await prisma.products.findUniqueOrThrow({
+    where: { Id: productId },
+    select: { ProjectId: true }
+  });
+  const project = await prisma.projects.findUniqueOrThrow({
+    where: {
+      Id: product.ProjectId
+    },
+    select: {
+      Products: {
+        where: {
+          Id: { not: productId }
+        },
+        select: {
+          WorkflowInstance: {
+            select: {
+              Id: true
+            }
+          },
+          DateUpdated: true
+        }
+      },
+      DateActive: true
+    }
+  });
+
+  const projectDateActive = project.DateActive;
+
+  let dateActive = new Date(0);
+  project.Products.forEach((product) => {
+    if (product.WorkflowInstance) {
+      if (product.DateUpdated && product.DateUpdated > dateActive) {
+        dateActive = product.DateUpdated;
+      }
+    }
+  });
+
+  if (dateActive > new Date(0)) {
+    project.DateActive = dateActive;
+  } else {
+    project.DateActive = null;
+  }
+
+  if (project.DateActive != projectDateActive) {
+    await projectUpdate(product.ProjectId, { DateActive: project.DateActive });
+  }
+  return prisma.workflowInstances.delete({ where: { ProductId: productId } });
+}
+export { deleteInstance as delete };
+
diff --git a/source/SIL.AppBuilder.Portal/common/databaseProxy/index.ts b/source/SIL.AppBuilder.Portal/common/databaseProxy/index.ts
index e081ba9a9..187a36182 100644
--- a/source/SIL.AppBuilder.Portal/common/databaseProxy/index.ts
+++ b/source/SIL.AppBuilder.Portal/common/databaseProxy/index.ts
@@ -1,7 +1,7 @@
 import { Prisma, PrismaClient } from '@prisma/client';
 
-import { WRITE_METHODS } from '../ReadonlyPrisma.js';
 import prisma from '../prisma.js';
+import { WRITE_METHODS } from '../ReadonlyPrisma.js';
 import * as groupMemberships from './GroupMemberships.js';
 import * as groups from './Groups.js';
 import * as organizationMemberships from './OrganizationMemberships.js';
@@ -11,6 +11,7 @@ import * as products from './Products.js';
 import * as projects from './Projects.js';
 import * as userRoles from './UserRoles.js';
 import * as utility from './utility.js';
+import * as workflowInstances from './WorkflowInstances.js';
 
 type RecurseRemove<T, V> = {
   [K in keyof T]: T[K] extends V | null | undefined ? never : RecurseRemove<T[K], V>;
@@ -41,7 +42,8 @@ const handlers = {
   organizationProductDefinitions,
   organizationMemberships,
   userRoles,
-  utility
+  utility,
+  workflowInstances
 };
 // @ts-expect-error this is in fact immediately populated
 const obj: DataType = {};
diff --git a/source/SIL.AppBuilder.Portal/common/databaseProxy/utility.ts b/source/SIL.AppBuilder.Portal/common/databaseProxy/utility.ts
index 7a17abd74..f025336d0 100644
--- a/source/SIL.AppBuilder.Portal/common/databaseProxy/utility.ts
+++ b/source/SIL.AppBuilder.Portal/common/databaseProxy/utility.ts
@@ -1,7 +1,7 @@
 import prisma from '../prisma.js';
 
 export type RequirePrimitive<T> = {
-  [K in keyof T]: Extract<T[K], string | number | boolean | Date>;
+  [K in keyof T]: Extract<T[K], string | number | boolean | Date | null>;
 };
 
 export async function getUserIfExists(externalId: string) {
diff --git a/source/SIL.AppBuilder.Portal/common/public/prisma.ts b/source/SIL.AppBuilder.Portal/common/public/prisma.ts
index de7a6468d..106ce3ffa 100644
--- a/source/SIL.AppBuilder.Portal/common/public/prisma.ts
+++ b/source/SIL.AppBuilder.Portal/common/public/prisma.ts
@@ -18,3 +18,5 @@ export enum WorkflowType {
   Rebuild,
   Republish
 }
+
+export const WorkflowTypeString = ['', 'Startup', 'Rebuild', 'Republish'];
diff --git a/source/SIL.AppBuilder.Portal/common/public/workflow.ts b/source/SIL.AppBuilder.Portal/common/public/workflow.ts
index d629f9e79..4931a2b5b 100644
--- a/source/SIL.AppBuilder.Portal/common/public/workflow.ts
+++ b/source/SIL.AppBuilder.Portal/common/public/workflow.ts
@@ -68,7 +68,7 @@ export enum WorkflowAction {
   Publish_Failed = 'Publish Failed'
 }
 
-export type WorkflowContextBase = {
+export type WorkflowInstanceContext = {
   instructions:
     | 'asset_package_verify_and_publish'
     | 'app_configuration'
@@ -98,11 +98,31 @@ export type WorkflowContextBase = {
   includeReviewers: boolean;
   includeArtifacts: 'apk' | 'aab' | boolean;
   start?: WorkflowState;
-  // Not sure how this is used, but will figure out when integrating into backend
-  environment: { [key: string]: unknown };
+  environment: Environment;
 };
 
-export type WorkflowContext = WorkflowContextBase & WorkflowInput;
+export type Environment = { [key: ENVKeys | string]: string };
+
+export enum ENVKeys {
+  // Set by Workflow
+  PUBLISH_GOOGLE_PLAY_UPLOADED_BUILD_ID = 'PUBLISH_GOOGLE_PLAY_UPLOADED_BUILD_ID',
+  PUBLISH_GOOGLE_PLAY_UPLOADED_VERSION_CODE = 'PUBLISH_GOOGLE_PLAY_UPLOADED_VERSION_CODE',
+  GOOGLE_PLAY_EXISTING = 'GOOGLE_PLAY_EXISTING',
+  GOOGLE_PLAY_DRAFT = 'GOOGLE_PLAY_DRAFT',
+  // Before Build
+  UI_URL = 'UI_URL',
+  PRODUCT_ID = 'PRODUCT_ID',
+  PROJECT_ID = 'PROJECT_ID',
+  PROJECT_NAME = 'PROJECT_NAME',
+  PROJECT_DESCRIPTION = 'PROJECT_DESCRIPTION',
+  PROJECT_URL = 'PROJECT_URL',
+  PROJECT_LANGUAGE = 'PROJECT_LANGUAGE',
+  PROJECT_ORGANIZATION = 'PROJECT_ORGANIZATION',
+  PROJECT_OWNER_NAME = 'PROJECT_OWNER_NAME',
+  PROJECT_OWNER_EMAIL = 'PROJECT_OWNER_EMAIL'
+}
+
+export type WorkflowContext = WorkflowInstanceContext & WorkflowInput;
 
 export type WorkflowConfig = {
   options: Set<WorkflowOptions>;
@@ -243,6 +263,6 @@ export type Snapshot = {
   instanceId: number;
   definitionId: number;
   state: string;
-  context: WorkflowContextBase;
+  context: WorkflowInstanceContext;
   config: WorkflowConfig;
 };
diff --git a/source/SIL.AppBuilder.Portal/common/workflow/index.ts b/source/SIL.AppBuilder.Portal/common/workflow/index.ts
index 690e9407d..6dd554600 100644
--- a/source/SIL.AppBuilder.Portal/common/workflow/index.ts
+++ b/source/SIL.AppBuilder.Portal/common/workflow/index.ts
@@ -17,8 +17,8 @@ import type {
   StateNode,
   WorkflowConfig,
   WorkflowContext,
-  WorkflowContextBase,
-  WorkflowEvent
+  WorkflowEvent,
+  WorkflowInstanceContext
 } from '../public/workflow.js';
 import {
   ActionType,
@@ -64,6 +64,7 @@ export class Workflow {
     });
 
     flow.flow.start();
+    await flow.createSnapshot(flow.flow.getSnapshot().context);
     await DatabaseWrites.productTransitions.create({
       data: {
         ProductId: productId,
@@ -119,7 +120,7 @@ export class Workflow {
   }
 
   /** Send a transition event to the workflow. */
-  public async send(event: WorkflowEvent): Promise<void> {
+  public send(event: WorkflowEvent): void {
     this.flow?.send(event);
   }
 
@@ -134,7 +135,7 @@ export class Workflow {
 
   /** Retrieves the workflow's snapshot from the database */
   public static async getSnapshot(productId: string): Promise<Snapshot | null> {
-    const snap = await prisma.workflowInstances.findUnique({
+    const instance = await prisma.workflowInstances.findUnique({
       where: {
         ProductId: productId
       },
@@ -151,17 +152,17 @@ export class Workflow {
         }
       }
     });
-    if (!snap) {
+    if (!instance) {
       return null;
     }
     return {
-      instanceId: snap.Id,
-      definitionId: snap.WorkflowDefinition.Id,
-      state: snap.State,
-      context: JSON.parse(snap.Context) as WorkflowContextBase,
+      instanceId: instance.Id,
+      definitionId: instance.WorkflowDefinition.Id,
+      state: instance.State,
+      context: JSON.parse(instance.Context) as WorkflowInstanceContext,
       config: {
-        productType: snap.WorkflowDefinition.ProductType,
-        options: new Set(snap.WorkflowDefinition.WorkflowOptions)
+        productType: instance.WorkflowDefinition.ProductType,
+        options: new Set(instance.WorkflowDefinition.WorkflowOptions)
       }
     };
   }
@@ -246,10 +247,10 @@ export class Workflow {
   /* PRIVATE METHODS */
   private async inspect(event: InspectedSnapshotEvent): Promise<void> {
     const old = this.currentState;
-    const snap = this.flow!.getSnapshot();
-    this.currentState = StartupWorkflow.getStateNodeById(`#${StartupWorkflow.id}.${snap.value}`);
+    const xSnap = this.flow!.getSnapshot();
+    this.currentState = StartupWorkflow.getStateNodeById(`#${StartupWorkflow.id}.${xSnap.value}`);
 
-    if (old && Workflow.stateName(old) !== snap.value) {
+    if (old && Workflow.stateName(old) !== xSnap.value) {
       await this.updateProductTransitions(
         event.event.userId,
         Workflow.stateName(old),
@@ -270,14 +271,10 @@ export class Workflow {
           ProductId: this.productId
         }
       });
-      if (snap.value in TerminalStates) {
-        await DatabaseWrites.workflowInstances.delete({
-          where: {
-            ProductId: this.productId
-          }
-        });
+      if (xSnap.value in TerminalStates) {
+        await DatabaseWrites.workflowInstances.delete(this.productId);
       } else {
-        await this.createSnapshot(snap.context);
+        await this.createSnapshot(xSnap.context);
         // This will also create the dummy entries in the ProductTransitions table
         await Queues.UserTasks.add(`Update UserTasks for Product #${this.productId}`, {
           type: BullMQ.JobType.UserTasks_Modify,
@@ -300,7 +297,7 @@ export class Workflow {
       hasReviewers: undefined,
       productType: undefined,
       options: undefined
-    } as WorkflowContextBase;
+    } as WorkflowInstanceContext;
     return DatabaseWrites.workflowInstances.upsert({
       where: {
         ProductId: this.productId
@@ -479,7 +476,8 @@ export class Workflow {
           DestinationState: destinationState,
           Command: command ?? null,
           DateTransition: new Date(),
-          Comment: comment ?? null
+          Comment: comment ?? null,
+          WorkflowType: WorkflowType.Startup // TODO: Change this once we support more workflow types
         }
       });
     }
diff --git a/source/SIL.AppBuilder.Portal/common/workflow/startup-workflow.ts b/source/SIL.AppBuilder.Portal/common/workflow/startup-workflow.ts
index b278c3a6c..972c5396c 100644
--- a/source/SIL.AppBuilder.Portal/common/workflow/startup-workflow.ts
+++ b/source/SIL.AppBuilder.Portal/common/workflow/startup-workflow.ts
@@ -1,4 +1,5 @@
 import { assign, setup } from 'xstate';
+import { BullMQ, Queues } from '../index.js';
 import { RoleId } from '../public/prisma.js';
 import type {
   WorkflowContext,
@@ -9,6 +10,7 @@ import type {
 } from '../public/workflow.js';
 import {
   ActionType,
+  ENVKeys,
   ProductType,
   WorkflowAction,
   WorkflowOptions,
@@ -243,9 +245,12 @@ export const StartupWorkflow = setup({
     [WorkflowState.Product_Creation]: {
       entry: [
         assign({ instructions: 'waiting' }),
-        () => {
-          // TODO: hook into build engine
-          console.log('Creating Product');
+        ({ context }) => {
+          Queues.Miscellaneous.add(`Create Product #${context.productId}`, {
+            type: BullMQ.JobType.Product_Create,
+            productId: context.productId
+          },
+          BullMQ.Retry5e5);
         }
       ],
       on: {
@@ -293,10 +298,10 @@ export const StartupWorkflow = setup({
             }
           },
           actions: assign({
-            environment: ({ context }) => {
-              context.environment.googlePlayExisting = true;
-              return context.environment;
-            }
+            environment: ({ context }) => ({
+              ...context.environment,
+              [ENVKeys.GOOGLE_PLAY_EXISTING]: '1'
+            })
           }),
           target: WorkflowState.Product_Build
         },
@@ -343,7 +348,11 @@ export const StartupWorkflow = setup({
     [WorkflowState.Synchronize_Data]: {
       entry: assign({
         instructions: 'synchronize_data',
-        includeFields: ['storeDescription', 'listingLanguageCode']
+        includeFields: ['storeDescription', 'listingLanguageCode'],
+        includeArtifacts: true
+      }),
+      exit: assign({
+        includeArtifacts: false
       }),
       on: {
         [WorkflowAction.Continue]: {
@@ -425,9 +434,22 @@ export const StartupWorkflow = setup({
         assign({
           instructions: 'waiting'
         }),
-        () => {
-          // TODO: hook into build engine
-          console.log('Building Product');
+        ({ context }) => {
+          Queues.Builds.add(`Build Product #${context.productId}`, {
+            type: BullMQ.JobType.Build_Product,
+            productId: context.productId,
+            defaultTargets: context.productType === ProductType.Android_S3
+              ? 'apk'
+              : context.productType === ProductType.AssetPackage
+                ? 'asset-package'
+                : context.productType === ProductType.Web
+                  ? 'html'
+                  : //ProductType.Android_GooglePlay
+                //default
+                  'apk play-listing',
+            environment: context.environment
+          },
+          BullMQ.Retry5e5);
         }
       ],
       on: {
@@ -441,15 +463,15 @@ export const StartupWorkflow = setup({
             },
             guard: ({ context }) =>
               context.productType === ProductType.Android_GooglePlay &&
-              !context.environment.googlePlayUploaded,
+              !context.environment[ENVKeys.PUBLISH_GOOGLE_PLAY_UPLOADED_BUILD_ID],
             target: WorkflowState.App_Store_Preview
           },
           {
             meta: { type: ActionType.Auto },
             guard: ({ context }) =>
               context.productType !== ProductType.Android_GooglePlay ||
-              !!context.environment.googlePlayExisting ||
-              !!context.environment.googlePlayUploaded,
+              context.environment[ENVKeys.GOOGLE_PLAY_EXISTING] === '1' ||
+              !!context.environment[ENVKeys.PUBLISH_GOOGLE_PLAY_UPLOADED_BUILD_ID],
             target: WorkflowState.Verify_and_Publish
           }
         ],
@@ -538,10 +560,10 @@ export const StartupWorkflow = setup({
         instructions: 'create_app_entry',
         includeFields: ['storeDescription', 'listingLanguageCode'],
         includeArtifacts: true,
-        environment: ({ context }) => {
-          context.environment.googlePlayDraft = true;
-          return context.environment;
-        }
+        environment: ({ context }) => ({
+          ...context.environment,
+          [ENVKeys.GOOGLE_PLAY_DRAFT]: '1'
+        })
       }),
       exit: assign({ includeArtifacts: false }),
       on: {
@@ -554,12 +576,13 @@ export const StartupWorkflow = setup({
                 options: { has: WorkflowOptions.AdminStoreAccess }
               }
             },
-            actions: assign({
-              environment: ({ context }) => {
-                context.environment.googlePlayUploaded = true;
-                return context.environment;
-              }
-            }),
+            actions: ({ context }) => {
+              // Given that the Set Google Play Uploaded action in S1 require DB and BuildEngine queries, this is probably the best way to do this
+              Queues.Miscellaneous.add(`Get VersionCode for Product #${context.productId}`, {
+                type: BullMQ.JobType.Product_GetVersionCode,
+                productId: context.productId
+              });
+            },
             target: WorkflowState.Verify_and_Publish
           },
           {
@@ -570,12 +593,12 @@ export const StartupWorkflow = setup({
                 options: { none: new Set([WorkflowOptions.AdminStoreAccess]) }
               }
             },
-            actions: assign({
-              environment: ({ context }) => {
-                context.environment.googlePlayUploaded = true;
-                return context.environment;
-              }
-            }),
+            actions: ({ context }) => {
+              Queues.Miscellaneous.add(`Get VersionCode for Product #${context.productId}`, {
+                type: BullMQ.JobType.Product_GetVersionCode,
+                productId: context.productId
+              });
+            },
             target: WorkflowState.Verify_and_Publish
           }
         ],
@@ -665,9 +688,23 @@ export const StartupWorkflow = setup({
     [WorkflowState.Product_Publish]: {
       entry: [
         assign({ instructions: 'waiting' }),
-        () => {
-          // TODO: hook into build engine
-          console.log('Publishing Product');
+        ({ context }) => {
+          Queues.Publishing.add(`Publish Product #${context.productId}`, {
+            type: BullMQ.JobType.Publish_Product,
+            productId: context.productId,
+            defaultChannel: 'production', //default unless overriden by WorkflowDefinition.Properties or ProductDefinition.Properties
+            defaultTargets: 
+            context.productType === ProductType.Android_S3
+              ? 's3-bucket'
+              : context.productType === ProductType.Web
+                ? 'rclone'
+                : //ProductType.Android_GooglePlay
+                //ProductType.AssetPackage
+                //default
+                'google-play',
+            environment: context.environment
+          },
+          BullMQ.Retry5e5);
         }
       ],
       on: {
@@ -681,14 +718,14 @@ export const StartupWorkflow = setup({
             },
             guard: ({ context }) =>
               context.productType === ProductType.Android_GooglePlay &&
-              !context.environment.googlePlayExisting,
+              !context.environment[ENVKeys.GOOGLE_PLAY_EXISTING],
             target: WorkflowState.Make_It_Live
           },
           {
             meta: { type: ActionType.Auto },
             guard: ({ context }) =>
               context.productType !== ProductType.Android_GooglePlay ||
-              !!context.environment.googlePlayExisting,
+              context.environment[ENVKeys.GOOGLE_PLAY_EXISTING] === '1',
             target: WorkflowState.Published
           }
         ],
diff --git a/source/SIL.AppBuilder.Portal/node-server/BullWorker.ts b/source/SIL.AppBuilder.Portal/node-server/BullWorker.ts
index 2c577a32d..8bc6b79b9 100644
--- a/source/SIL.AppBuilder.Portal/node-server/BullWorker.ts
+++ b/source/SIL.AppBuilder.Portal/node-server/BullWorker.ts
@@ -14,6 +14,18 @@ export abstract class BullWorker<T> {
   abstract run(job: Job<T>): Promise<unknown>;
 }
 
+export class Builds extends BullWorker<BullMQ.Job> {
+  constructor() {
+    super(BullMQ.QueueName.Builds);
+  }
+  async run(job: Job<BullMQ.Job>) {
+    switch (job.data.type) {
+    case BullMQ.JobType.Build_Product:
+      return Executor.Build.product(job as Job<BullMQ.Build.Product>);
+    }
+  }
+}
+
 export class DefaultRecurring extends BullWorker<BullMQ.Job> {
   constructor() {
     super(BullMQ.QueueName.DefaultRecurring);
@@ -38,6 +50,48 @@ export class DefaultRecurring extends BullWorker<BullMQ.Job> {
   }
 }
 
+export class Miscellaneous extends BullWorker<BullMQ.Job> {
+  constructor() {
+    super(BullMQ.QueueName.Miscellaneous);
+  }
+  async run(job: Job<BullMQ.Job>) {
+    switch (job.data.type) {
+    case BullMQ.JobType.Product_Create:
+      return Executor.Product.create(job as Job<BullMQ.Product.Create>);
+    case BullMQ.JobType.Product_Delete:
+      return Executor.Product.deleteProduct(job as Job<BullMQ.Product.Delete>);
+    case BullMQ.JobType.Product_GetVersionCode:
+      return Executor.Product.getVersionCode(job as Job<BullMQ.Product.GetVersionCode>);
+    }
+  }
+}
+
+export class Publishing extends BullWorker<BullMQ.Job> {
+  constructor() {
+    super(BullMQ.QueueName.Publishing);
+  }
+  async run(job: Job<BullMQ.Job>) {
+    switch (job.data.type) {
+    case BullMQ.JobType.Publish_Product:
+      return Executor.Publish.product(job as Job<BullMQ.Publish.Product>);
+    }
+  }
+}
+
+export class RemotePolling extends BullWorker<BullMQ.Job> {
+  constructor() {
+    super(BullMQ.QueueName.RemotePolling);
+  }
+  async run(job: Job<BullMQ.Job>) {
+    switch (job.data.type) {
+    case BullMQ.JobType.Build_Check:
+      return Executor.Build.check(job as Job<BullMQ.Build.Check>);
+    case BullMQ.JobType.Publish_Check:
+      return Executor.Publish.check(job as Job<BullMQ.Publish.Check>);
+    }
+  }
+}
+
 export class UserTasks extends BullWorker<BullMQ.Job> {
   constructor() {
     super(BullMQ.QueueName.UserTasks);
diff --git a/source/SIL.AppBuilder.Portal/node-server/dev.ts b/source/SIL.AppBuilder.Portal/node-server/dev.ts
index 2ddc0ec66..e98c90e4c 100644
--- a/source/SIL.AppBuilder.Portal/node-server/dev.ts
+++ b/source/SIL.AppBuilder.Portal/node-server/dev.ts
@@ -18,5 +18,9 @@ createBullBoard({
 app.use(serverAdapter.getRouter());
 app.listen(3000, () => console.log('Dev server started'));
 
+new Workers.Builds();
 new Workers.DefaultRecurring();
+new Workers.Miscellaneous();
+new Workers.Publishing();
+new Workers.RemotePolling();
 new Workers.UserTasks();
diff --git a/source/SIL.AppBuilder.Portal/node-server/index.ts b/source/SIL.AppBuilder.Portal/node-server/index.ts
index 03b4d6689..a5b150a9b 100644
--- a/source/SIL.AppBuilder.Portal/node-server/index.ts
+++ b/source/SIL.AppBuilder.Portal/node-server/index.ts
@@ -75,7 +75,11 @@ app.get('/healthcheck', (req, res) => {
 // Running on svelte process right now. Consider putting on new thread
 // Fine like this if majority of job time is waiting for network requests
 // If there is much processing it should be moved to another thread
+new Workers.Builds();
 new Workers.DefaultRecurring();
+new Workers.Miscellaneous();
+new Workers.Publishing();
+new Workers.RemotePolling();
 new Workers.UserTasks();
 
 const serverAdapter = new ExpressAdapter();
diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/build.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/build.ts
new file mode 100644
index 000000000..da6fd9187
--- /dev/null
+++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/build.ts
@@ -0,0 +1,208 @@
+import { Job } from 'bullmq';
+import {
+  BuildEngine,
+  BullMQ,
+  DatabaseWrites,
+  prisma,
+  Queues,
+  Workflow
+} from 'sil.appbuilder.portal.common';
+import { WorkflowAction } from 'sil.appbuilder.portal.common/workflow';
+import {
+  addProductPropertiesToEnvironment,
+  getWorkflowParameters
+} from './common.build-publish.js';
+
+export async function product(job: Job<BullMQ.Build.Product>): Promise<unknown> {
+  const productData = await prisma.products.findUnique({
+    where: {
+      Id: job.data.productId
+    },
+    select: {
+      Project: {
+        select: {
+          OrganizationId: true
+        }
+      },
+      WorkflowJobId: true,
+      WorkflowInstance: {
+        select: {
+          Id: true
+        }
+      }
+    }
+  });
+  if (!productData) {
+    throw new Error(`Product #${job.data.productId} does not exist!`);
+  }
+  job.updateProgress(10);
+  // reset previous build
+  await DatabaseWrites.products.update(job.data.productId, {
+    WorkflowBuildId: 0
+  });
+  job.updateProgress(20);
+  const params = await getWorkflowParameters(productData.WorkflowInstance.Id, 'build');
+  job.updateProgress(30);
+  const env = await addProductPropertiesToEnvironment(job.data.productId);
+  job.updateProgress(40);
+  const response = await BuildEngine.Requests.createBuild(
+    { type: 'query', organizationId: productData.Project.OrganizationId },
+    productData.WorkflowJobId,
+    {
+      targets: params['targets'] ?? job.data.defaultTargets,
+      environment: { ...env, ...params.environment, ...job.data.environment }
+    }
+  );
+  job.updateProgress(50);
+  if (response.responseType === 'error') {
+    const flow = await Workflow.restore(job.data.productId);
+    // TODO: Send Notification of Failure
+    flow.send({ type: WorkflowAction.Build_Failed, userId: null, comment: response.message });
+  } else {
+    await DatabaseWrites.products.update(job.data.productId, {
+      WorkflowBuildId: response.id
+    });
+    job.updateProgress(65);
+
+    const productBuild = await DatabaseWrites.productBuilds.create({
+      data: {
+        ProductId: job.data.productId,
+        BuildId: response.id
+      }
+    });
+
+    job.updateProgress(85);
+
+    await Queues.RemotePolling.add(
+      `Check status of Build #${response.id}`,
+      {
+        type: BullMQ.JobType.Build_Check,
+        productId: job.data.productId,
+        organizationId: productData.Project.OrganizationId,
+        jobId: productData.WorkflowJobId,
+        buildId: response.id,
+        productBuildId: productBuild.Id
+      },
+      BullMQ.RepeatEveryMinute
+    );
+  }
+  job.updateProgress(100);
+  return {
+    response,
+    params,
+    env
+  };
+}
+
+export async function check(job: Job<BullMQ.Build.Check>): Promise<unknown> {
+  const response = await BuildEngine.Requests.getBuild(
+    { type: 'query', organizationId: job.data.organizationId },
+    job.data.jobId,
+    job.data.buildId
+  );
+  job.updateProgress(50);
+  if (response.responseType === 'error') {
+    throw new Error(response.message);
+  } else {
+    if (response.status === 'completed') {
+      await Queues.RemotePolling.removeRepeatableByKey(job.repeatJobKey);
+      if (response.error) {
+        job.log(response.error);
+      }
+      let latestArtifactDate = new Date(0);
+      job.log('ARTIFACTS:');
+      await DatabaseWrites.productArtifacts.createMany({
+        data: await Promise.all(
+          Object.entries(response.artifacts).map(async ([type, url]) => {
+            job.log(`${type}: ${url}`);
+            const res = await fetch(url, { method: 'HEAD' });
+            const lastModified = new Date(res.headers.get('Last-Modified'));
+            if (lastModified > latestArtifactDate) {
+              latestArtifactDate = lastModified;
+            }
+
+            // On version.json, update the ProductBuild.Version
+            if (type === 'version' && res.headers.get('Content-Type') === 'application/json') {
+              const version = JSON.parse(await fetch(url).then((r) => r.text()));
+              if (version['version']) {
+                await DatabaseWrites.productBuilds.update({
+                  where: {
+                    Id: job.data.productBuildId
+                  },
+                  data: {
+                    Version: version['version']
+                  }
+                });
+                if (response.result === 'SUCCESS') {
+                  await DatabaseWrites.products.update(job.data.productId, {
+                    VersionBuilt: version['version']
+                  });
+                }
+              }
+            }
+
+            // On play-listing-manifest.json, update the Project.DefaultLanguage
+            if (
+              type == 'play-listing-manifest' &&
+              res.headers.get('Content-Type') === 'application/json'
+            ) {
+              const manifest = JSON.parse(await fetch(url).then((r) => r.text()));
+              if (manifest['default-language']) {
+                const lang = await prisma.storeLanguages.findFirst({
+                  where: {
+                    Name: manifest['default-language']
+                  },
+                  select: {
+                    Id: true
+                  }
+                });
+                if (lang !== null) {
+                  await DatabaseWrites.products.update(job.data.productId, {
+                    StoreLanguageId: lang.Id
+                  });
+                }
+              }
+            }
+
+            return {
+              ProductId: job.data.productId,
+              ProductBuildId: job.data.productBuildId,
+              ArtifactType: type,
+              Url: url,
+              ContentType: res.headers.get('Content-Type'),
+              FileSize:
+                res.headers.get('Content-Type') !== 'text/html'
+                  ? parseInt(res.headers.get('Content-Length'))
+                  : undefined
+            };
+          })
+        )
+      });
+      await DatabaseWrites.products.update(job.data.productId, {
+        DateBuilt: latestArtifactDate
+      });
+      job.updateProgress(80);
+      await DatabaseWrites.productBuilds.update({
+        where: {
+          Id: job.data.productBuildId
+        },
+        data: {
+          Success: response.result === 'SUCCESS'
+        }
+      });
+      job.updateProgress(90);
+      const flow = await Workflow.restore(job.data.productId);
+      if (response.result === 'SUCCESS') {
+        flow.send({ type: WorkflowAction.Build_Successful, userId: null });
+      } else {
+        flow.send({
+          type: WorkflowAction.Build_Failed,
+          userId: null,
+          comment: `system.build-failed,${response.artifacts['consoleText'] ?? ''}`
+        });
+      }
+    }
+    job.updateProgress(100);
+    return response;
+  }
+}
diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/common.build-publish.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/common.build-publish.ts
new file mode 100644
index 000000000..96828c897
--- /dev/null
+++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/common.build-publish.ts
@@ -0,0 +1,139 @@
+import { prisma } from 'sil.appbuilder.portal.common';
+import { WorkflowTypeString } from 'sil.appbuilder.portal.common/prisma';
+import { Environment, ENVKeys } from 'sil.appbuilder.portal.common/workflow';
+
+export async function addProductPropertiesToEnvironment(productId: string) {
+  const product = await prisma.products.findUnique({
+    where: {
+      Id: productId
+    },
+    select: {
+      Project: {
+        select: {
+          Id: true,
+          Name: true,
+          Description: true,
+          Language: true,
+          Organization: {
+            select: {
+              Name: true
+            }
+          },
+          Owner: {
+            select: {
+              Name: true,
+              Email: true
+            }
+          }
+        }
+      },
+      Properties: true
+    }
+  });
+  const uiUrl = process.env.UI_URL || 'http://localhost:5173';
+  const projectUrl = uiUrl + '/projects/' + product.Project.Id;
+
+  return {
+    [ENVKeys.UI_URL]: uiUrl,
+    [ENVKeys.PRODUCT_ID]: productId,
+    [ENVKeys.PROJECT_ID]: '' + product.Project.Id,
+    [ENVKeys.PROJECT_NAME]: product.Project.Name ?? '',
+    [ENVKeys.PROJECT_DESCRIPTION]: product.Project.Description ?? '',
+    [ENVKeys.PROJECT_URL]: projectUrl,
+    [ENVKeys.PROJECT_LANGUAGE]: product.Project.Language ?? '',
+    [ENVKeys.PROJECT_ORGANIZATION]: product.Project.Organization.Name,
+    [ENVKeys.PROJECT_OWNER_NAME]: product.Project.Owner.Name,
+    [ENVKeys.PROJECT_OWNER_EMAIL]: product.Project.Owner.Email,
+    ...(product.Properties ? JSON.parse(product.Properties).environment ?? {} : {})
+  } as Environment;
+}
+
+export async function getWorkflowParameters(workflowInstanceId: number, scope: 'build' | 'publish') {
+  const instance = await prisma.workflowInstances.findUnique({
+    where: {
+      Id: workflowInstanceId
+    },
+    select: {
+      Product: {
+        select: {
+          ProductDefinition: {
+            select: {
+              Properties: true,
+              Name: true
+            }
+          }
+        }
+      },
+      WorkflowDefinition: {
+        select: {
+          Properties: true,
+          Type: true
+        }
+      }
+    }
+  });
+  let environment: Environment = {
+    WORKFLOW_TYPE: WorkflowTypeString[instance.WorkflowDefinition.Type],
+    WORKFLOW_PRODUCT_NAME: instance.Product.ProductDefinition.Name
+  };
+  
+  const result: { [key: string]: string } = {};
+  const scoped: { [key: string]: string } = {};
+  Object.entries(JSON.parse(instance.WorkflowDefinition.Properties ?? '{}')).forEach(([k, v]) => {
+    const strValue = JSON.stringify(v);
+    let strKey = k;
+    if (strKey === 'environment') {
+      // merge environment
+      environment = {
+        ...environment,
+        ...JSON.parse(strValue)
+      };
+    }
+    // Allow for scoped names so "build:targets" will become "targets"
+    // Scoped values should be assigned after non-scoped
+    else if (strKey.includes(':')) {
+      // Use scoped values for this scope and ignore others
+      if (scope && strKey.startsWith(scope + ':')) {
+        strKey = strKey.split(':')[1];
+        scoped[strKey] = strValue;
+      }
+    } else {
+      result[strKey] = strValue;
+    }
+  });
+  Object.entries(JSON.parse(instance.Product.ProductDefinition.Properties ?? '{}')).forEach(([k, v]) => {
+    const strValue = JSON.stringify(v);
+    let strKey = k;
+    if (strKey === 'environment') {
+      // merge environment
+      environment = {
+        ...environment,
+        ...JSON.parse(strValue)
+      };
+    }
+    // Allow for scoped names so "build:targets" will become "targets"
+    // Scoped values should be assigned after non-scoped
+    else if (strKey.includes(':')) {
+      // Use scoped values for this scope and ignore others
+      if (scope && strKey.startsWith(scope + ':')) {
+        strKey = strKey.split(':')[1];
+        scoped[strKey] = strValue;
+      }
+    } else {
+      result[strKey] = strValue;
+    }
+  });
+  Object.entries(scoped).forEach(([k, v]) => {
+    if (k === 'environment') {
+      environment = {
+        ...environment,
+        ...JSON.parse(v)
+      }
+    }
+    else {
+      result[k] = v;
+    }
+  });
+
+  return { ...result, environment: environment };
+}
diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/index.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/index.ts
index f0bab14d9..058c8bdad 100644
--- a/source/SIL.AppBuilder.Portal/node-server/job-executors/index.ts
+++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/index.ts
@@ -1,2 +1,5 @@
+export * as Build from './build.js';
+export * as Product from './product.js';
+export * as Publish from './publish.js';
 export * as System from './system.js';
 export * as UserTasks from './userTasks.js';
diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/product.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/product.ts
new file mode 100644
index 000000000..da4054c42
--- /dev/null
+++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/product.ts
@@ -0,0 +1,169 @@
+import { Job } from 'bullmq';
+import {
+  BuildEngine,
+  BullMQ,
+  DatabaseWrites,
+  prisma,
+  Workflow
+} from 'sil.appbuilder.portal.common';
+import {
+  ENVKeys,
+  WorkflowAction,
+  WorkflowInstanceContext
+} from 'sil.appbuilder.portal.common/workflow';
+
+export async function create(job: Job<BullMQ.Product.Create>): Promise<unknown> {
+  const productData = await prisma.products.findUnique({
+    where: {
+      Id: job.data.productId
+    },
+    select: {
+      Project: {
+        select: {
+          ApplicationType: {
+            select: {
+              Name: true
+            }
+          },
+          WorkflowProjectUrl: true,
+          OrganizationId: true
+        }
+      },
+      Store: {
+        select: {
+          Name: true
+        }
+      }
+    }
+  });
+  job.updateProgress(25);
+  const response = await BuildEngine.Requests.createJob(
+    { type: 'query', organizationId: productData.Project.OrganizationId },
+    {
+      request_id: job.data.productId,
+      git_url: productData.Project.WorkflowProjectUrl,
+      app_id: productData.Project.ApplicationType.Name,
+      publisher_id: productData.Store.Name
+    }
+  );
+  job.updateProgress(50);
+  if (response.responseType === 'error') {
+    // TODO: What do I do here? Wait some period of time and retry?
+    job.log(response.message);
+    throw new Error(response.message);
+  } else {
+    await DatabaseWrites.products.update(job.data.productId, {
+      WorkflowJobId: response.id
+    });
+    job.updateProgress(75);
+    const flow = await Workflow.restore(job.data.productId);
+
+    flow.send({ type: WorkflowAction.Product_Created, userId: null });
+
+    job.updateProgress(100);
+    return response;
+  }
+}
+
+export async function deleteProduct(job: Job<BullMQ.Product.Delete>): Promise<unknown> {
+  const response = await BuildEngine.Requests.deleteJob(
+    { type: 'query', organizationId: job.data.organizationId },
+    job.data.workflowJobId
+  );
+  job.updateProgress(50);
+  if (response.responseType === 'error') {
+    job.log(response.message);
+    throw new Error(response.message);
+  } else {
+    job.updateProgress(100);
+    return response.status;
+  }
+}
+
+export async function getVersionCode(job: Job<BullMQ.Product.GetVersionCode>): Promise<unknown> {
+  let versionCode = 0;
+  const product = await prisma.products.findUnique({
+    where: {
+      Id: job.data.productId
+    },
+    select: {
+      WorkflowBuildId: true,
+      WorkflowJobId: true,
+      Project: {
+        select: {
+          Organization: {
+            select: {
+              BuildEngineUrl: true,
+              BuildEngineApiAccessToken: true
+            }
+          }
+        }
+      }
+    }
+  });
+  job.updateProgress(30);
+  if (product?.WorkflowBuildId && product?.WorkflowJobId) {
+    const productBuild = await prisma.productBuilds.findFirst({
+      where: {
+        ProductId: job.data.productId,
+        BuildId: product.WorkflowBuildId
+      },
+      select: {
+        Id: true
+      }
+    });
+    if (!productBuild) {
+      return 0;
+    }
+    job.updateProgress(45);
+    const versionCodeArtifact = await prisma.productArtifacts.findFirst({
+      where: {
+        ProductId: job.data.productId,
+        ProductBuildId: productBuild.Id,
+        ArtifactType: 'version'
+      },
+      select: {
+        Url: true
+      }
+    });
+    if (!versionCodeArtifact) {
+      return 0;
+    }
+    job.updateProgress(60);
+    const version = JSON.parse(await fetch(versionCodeArtifact.Url).then((r) => r.text()));
+    if (version['versionCode'] !== undefined) {
+      versionCode = parseInt(version['versionCode']);
+    }
+    job.updateProgress(75);
+  }
+  if (versionCode) {
+    const instance = await prisma.workflowInstances.findUnique({
+      where: {
+        ProductId: job.data.productId
+      },
+      select: {
+        Id: true,
+        Context: true
+      }
+    });
+    const ctx: WorkflowInstanceContext = JSON.parse(instance.Context);
+    job.updateProgress(90);
+    await DatabaseWrites.workflowInstances.update({
+      where: {
+        Id: instance.Id
+      },
+      data: {
+        Context: JSON.stringify({
+          ...ctx,
+          environment: {
+            ...ctx.environment,
+            [ENVKeys.PUBLISH_GOOGLE_PLAY_UPLOADED_BUILD_ID]: '' + product.WorkflowBuildId,
+            [ENVKeys.PUBLISH_GOOGLE_PLAY_UPLOADED_VERSION_CODE]: '' + versionCode
+          }
+        } as WorkflowInstanceContext)
+      }
+    });
+  }
+  job.updateProgress(100);
+  return versionCode;
+}
diff --git a/source/SIL.AppBuilder.Portal/node-server/job-executors/publish.ts b/source/SIL.AppBuilder.Portal/node-server/job-executors/publish.ts
new file mode 100644
index 000000000..0b2a9c96b
--- /dev/null
+++ b/source/SIL.AppBuilder.Portal/node-server/job-executors/publish.ts
@@ -0,0 +1,195 @@
+import { Job } from 'bullmq';
+import {
+  BuildEngine,
+  BullMQ,
+  DatabaseWrites,
+  prisma,
+  Queues,
+  Workflow
+} from 'sil.appbuilder.portal.common';
+import { WorkflowAction } from 'sil.appbuilder.portal.common/workflow';
+import {
+  addProductPropertiesToEnvironment,
+  getWorkflowParameters
+} from './common.build-publish.js';
+
+export async function product(job: Job<BullMQ.Publish.Product>): Promise<unknown> {
+  const productData = await prisma.products.findUnique({
+    where: {
+      Id: job.data.productId
+    },
+    select: {
+      Project: {
+        select: {
+          OrganizationId: true
+        }
+      },
+      WorkflowJobId: true,
+      WorkflowBuildId: true,
+      WorkflowInstance: {
+        select: {
+          Id: true
+        }
+      }
+    }
+  });
+  job.updateProgress(10);
+  const productBuild = await prisma.productBuilds.findFirst({
+    where: {
+      BuildId: productData.WorkflowBuildId
+    },
+    select: {
+      Id: true
+    }
+  });
+  if (!productData.WorkflowBuildId || !productBuild) {
+    const flow = await Workflow.restore(job.data.productId);
+    // TODO: Send notification of failure
+    flow.send({
+      type: WorkflowAction.Publish_Failed,
+      userId: null,
+      comment: 'Product does not have a ProductBuild available.'
+    });
+    job.updateProgress(100);
+    return productData;
+  }
+  job.updateProgress(15);
+  await DatabaseWrites.products.update(job.data.productId, {
+    WorkflowPublishId: 0
+  });
+  job.updateProgress(20);
+  const params = await getWorkflowParameters(productData.WorkflowInstance.Id, 'publish');
+  const channel = params['channel'] ?? job.data.defaultChannel;
+  job.updateProgress(30);
+  const env = await addProductPropertiesToEnvironment(job.data.productId);
+  job.updateProgress(40);
+  const response = await BuildEngine.Requests.createRelease(
+    { type: 'query', organizationId: productData.Project.OrganizationId },
+    productData.WorkflowJobId,
+    productData.WorkflowBuildId,
+    {
+      channel: channel,
+      targets: params['targets'] ?? job.data.defaultTargets,
+      environment: { ...env, ...params.environment, ...job.data.environment }
+    }
+  );
+  job.updateProgress(50);
+  if (response.responseType === 'error') {
+    const flow = await Workflow.restore(job.data.productId);
+    // TODO: Send notification of failure
+    flow.send({ type: WorkflowAction.Publish_Failed, userId: null, comment: response.message });
+  } else {
+    await DatabaseWrites.products.update(job.data.productId, {
+      WorkflowPublishId: response.id
+    });
+    job.updateProgress(65);
+
+    const pub = await DatabaseWrites.productPublications.create({
+      data: {
+        ProductId: job.data.productId,
+        ProductBuildId: productBuild.Id,
+        ReleaseId: response.id,
+        Channel: channel
+      }
+    });
+
+    job.updateProgress(85);
+
+    await Queues.RemotePolling.add(
+      `Check status of Publish #${response.id}`,
+      {
+        type: BullMQ.JobType.Publish_Check,
+        productId: job.data.productId,
+        organizationId: productData.Project.OrganizationId,
+        jobId: productData.WorkflowJobId,
+        buildId: productData.WorkflowBuildId,
+        releaseId: response.id,
+        publicationId: pub.Id
+      },
+      BullMQ.RepeatEveryMinute
+    );
+    
+  }
+  job.updateProgress(100);
+  return {
+    response,
+    params,
+    env
+  };
+}
+
+export async function check(job: Job<BullMQ.Publish.Check>): Promise<unknown> {
+  const response = await BuildEngine.Requests.getRelease(
+    { type: 'query', organizationId: job.data.organizationId },
+    job.data.jobId,
+    job.data.buildId,
+    job.data.releaseId
+  );
+  job.updateProgress(50);
+  if (response.responseType === 'error') {
+    throw new Error(response.message);
+  } else {
+    if (response.status === 'completed') {
+      await Queues.RemotePolling.removeRepeatableByKey(job.repeatJobKey);
+      if (response.error) {
+        job.log(response.error);
+      }
+      let packageName: string | undefined = undefined;
+      const flow = await Workflow.restore(job.data.productId);
+      if (response.result === 'SUCCESS') {
+        const publishUrlFile = response.artifacts['publishUrl'];
+        await DatabaseWrites.products.update(job.data.productId, {
+          DatePublished: new Date(),
+          PublishLink: publishUrlFile
+            ? (await fetch(publishUrlFile).then((r) => r.text()))?.trim() ?? undefined
+            : undefined
+        });
+        flow.send({ type: WorkflowAction.Publish_Completed, userId: null });
+        const packageFile = await prisma.productPublications.findUnique({
+          where: {
+            Id: job.data.publicationId
+          },
+          select: {
+            ProductBuild: {
+              select: {
+                ProductArtifacts: {
+                  where: {
+                    ArtifactType: 'package_name'
+                  },
+                  select: {
+                    Url: true
+                  },
+                  take: 1
+                }
+              }
+            }
+          }
+        });
+        if (packageFile?.ProductBuild.ProductArtifacts[0]) {
+          packageName = await fetch(packageFile.ProductBuild.ProductArtifacts[0].Url).then((r) =>
+            r.text()
+          );
+        }
+      } else {
+        flow.send({
+          type: WorkflowAction.Publish_Failed,
+          userId: null,
+          comment: `system.publish-failed,${response.artifacts['consoleText'] ?? ''}`
+        });
+      }
+      job.updateProgress(80);
+      await DatabaseWrites.productPublications.update({
+        where: {
+          Id: job.data.publicationId
+        },
+        data: {
+          Success: response.result === 'SUCCESS',
+          LogUrl: response.console_text,
+          Package: packageName?.trim()
+        }
+      });
+    }
+    job.updateProgress(100);
+    return response;
+  }
+}
diff --git a/source/SIL.AppBuilder.Portal/node-server/tsconfig.dev.json b/source/SIL.AppBuilder.Portal/node-server/tsconfig.dev.json
index f9128699b..5dd2ecfe8 100644
--- a/source/SIL.AppBuilder.Portal/node-server/tsconfig.dev.json
+++ b/source/SIL.AppBuilder.Portal/node-server/tsconfig.dev.json
@@ -3,7 +3,7 @@
   "compilerOptions": {
     "esModuleInterop": true,
     "skipLibCheck": true,
-    "module": "ES6",
-    "moduleResolution": "node"
+    "module": "NodeNext",
+    "moduleResolution": "NodeNext"
   }
 }
diff --git a/source/SIL.AppBuilder.Portal/src/lib/components/ProductDetails.svelte b/source/SIL.AppBuilder.Portal/src/lib/components/ProductDetails.svelte
index a57c8cc0b..f2e541c41 100644
--- a/source/SIL.AppBuilder.Portal/src/lib/components/ProductDetails.svelte
+++ b/source/SIL.AppBuilder.Portal/src/lib/components/ProductDetails.svelte
@@ -2,6 +2,7 @@
   import * as m from '$lib/paraglide/messages';
   import { getTimeDateString } from '$lib/timeUtils';
   import { ProductTransitionType } from 'sil.appbuilder.portal.common/prisma';
+  import IconContainer from './IconContainer.svelte';
 
   export let product: {
     Id: string;
@@ -39,11 +40,24 @@
     }
     return '';
   }
+
+  let detailsModal: HTMLDialogElement;
 </script>
 
-<dialog id="modal{product.Id}" class="modal">
+<dialog bind:this={detailsModal} id="modal{product.Id}" class="modal">
   <div class="modal-box w-11/12 max-w-6xl">
-    <h2>{m.project_products_transitions_productDetails()}</h2>
+    <div class="flex flex-row">
+      <h2 class="grow">{m.project_products_transitions_productDetails()}</h2>
+      <button
+        class="btn btn-ghost"
+        type="button"
+        on:click={() => {
+          detailsModal?.close();
+        }}
+      >
+        <IconContainer icon="mdi:close" width={36} class="opacity-80" />
+      </button>
+    </div>
     <table class="table">
       <thead>
         <tr>
@@ -99,7 +113,11 @@
                   </span>
                 {/if}
                 <br />
-                <a href={transition.Comment.replace('system.build-failed,', '')}>
+                <a
+                  class="link link-info"
+                  href={transition.Comment.replace('system.build-failed,', '')}
+                  target="_blank"
+                >
                   {m.project_products_publications_console()}
                 </a>
               {:else}
@@ -124,4 +142,4 @@
   :root:has(:is(.modal-open, .modal:target, .modal-toggle:checked + .modal, .modal[open])) {
     scrollbar-gutter: unset;
   }
-</style>
\ No newline at end of file
+</style>
diff --git a/source/SIL.AppBuilder.Portal/src/lib/components/SortTable.svelte b/source/SIL.AppBuilder.Portal/src/lib/components/SortTable.svelte
index 184c203ef..800aa7600 100644
--- a/source/SIL.AppBuilder.Portal/src/lib/components/SortTable.svelte
+++ b/source/SIL.AppBuilder.Portal/src/lib/components/SortTable.svelte
@@ -3,10 +3,10 @@
   @component
 -->
 <script lang="ts">
-  import { languageTag } from '$lib/paraglide/runtime';
   import { ArrowDownIcon, ArrowUpIcon } from '$lib/icons';
+  import { languageTag } from '$lib/paraglide/runtime';
   import { createEventDispatcher } from 'svelte';
-  export let data: { [key: string]: any }[];
+  export let data: Record<string, any>[];
   /** Definition of the columns for the table */
   export let columns: {
     /** Internal id, used for determining which column is being sorted */
@@ -23,6 +23,15 @@
   export let className: string = '';
   /** If this is true, will defer sorting to the server instead */
   export let serverSide: boolean = false;
+  /** If this is defined, will handle a click on a row */
+  export let onRowClick:
+    | undefined
+    | ((
+        rowData: (typeof data)[0],
+        event: MouseEvent & {
+          currentTarget: EventTarget & HTMLTableRowElement;
+        }
+      ) => void) = undefined;
 
   /** Current field being sorted. Defaults to first field where `sortable === true` */
   let current = columns.find((c) => c.sortable)!;
@@ -115,7 +124,11 @@
     </thead>
     <tbody>
       {#each data as d}
-        <tr>
+        <tr
+          on:click={(e) => {
+            if (onRowClick) onRowClick(d, e);
+          }}
+        >
           {#each columns as c}
             <td>
               {#if c.render}
@@ -143,7 +156,7 @@
     line-height: inherit;
     position: sticky;
     top: 0;
-    @apply bg-base-100;
+    @apply bg-neutral;
   }
   .direction-arrow {
     display: inline-block;
@@ -154,4 +167,8 @@
   label {
     @apply select-none cursor-pointer;
   }
+  th,
+  td {
+    @apply border;
+  }
 </style>
diff --git a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts
index 87732ac05..cbbf41e2a 100644
--- a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts
+++ b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.server.ts
@@ -1,8 +1,8 @@
 import { verifyCanViewAndEdit } from '$lib/projects/common.server';
 import { idSchema } from '$lib/valibot';
 import { error } from '@sveltejs/kit';
-import { BullMQ, DatabaseWrites, prisma, Queues, Workflow } from 'sil.appbuilder.portal.common';
-import { RoleId, WorkflowType } from 'sil.appbuilder.portal.common/prisma';
+import { BullMQ, DatabaseWrites, prisma, Queues } from 'sil.appbuilder.portal.common';
+import { RoleId } from 'sil.appbuilder.portal.common/prisma';
 import { fail, superValidate } from 'sveltekit-superforms';
 import { valibot } from 'sveltekit-superforms/adapters';
 import * as v from 'valibot';
@@ -28,44 +28,115 @@ const updateOwnerGroupSchema = v.object({
 });
 const addProductSchema = v.object({
   productDefinitionId: idSchema,
-  storeId: idSchema,
-  storeLanguageId: idSchema,
-  workflowJobId: idSchema,
-  workflowBuildId: idSchema,
-  workflowPublishId: idSchema
+  storeId: idSchema
 });
 
 // Are we sending too much data?
+// Maybe? I pared it down a bit with `select` instead of `include` - Aidan
 export const load = (async ({ locals, params }) => {
   if (!verifyCanViewAndEdit((await locals.auth())!, parseInt(params.id))) return error(403);
   const project = await prisma.projects.findUnique({
     where: {
       Id: parseInt(params.id)
     },
-    include: {
-      ApplicationType: true,
+    select: {
+      Id: true,
+      Name: true,
+      Description: true,
+      WorkflowProjectUrl: true,
+      IsPublic: true,
+      AllowDownloads: true,
+      DateCreated: true,
+      Language: true,
+      ApplicationType: {
+        select: {
+          Description: true
+        }
+      },
+      Organization: {
+        select: {
+          Id: true
+        }
+      },
       Products: {
-        include: {
+        select: {
+          Id: true,
+          DateUpdated: true,
+          DatePublished: true,
           ProductDefinition: {
-            include: {
-              Workflow: true
+            select: {
+              Id: true,
+              Name: true
+            }
+          },
+          // Probably don't need to optimize this. Unless it's a really large org, there probably won't be very many of these records for an individual product. In most cases, there will only be zero or one. The only times there will be more is if it's an admin task or an author task.
+          UserTasks: {
+            select: {
+              DateCreated: true,
+              UserId: true
             }
           },
-          UserTasks: true,
-          Store: true
+          Store: {
+            select: {
+              Description: true
+            }
+          }
+        }
+      },
+      Owner: {
+        select: {
+          Id: true,
+          Name: true
+        }
+      },
+      Group: {
+        select: {
+          Id: true,
+          Name: true
         }
       },
-      Owner: true,
-      Group: true,
       Authors: {
-        include: {
-          Users: true
+        select: {
+          Id: true,
+          Users: {
+            select: {
+              Id: true,
+              Name: true
+            }
+          }
         }
       },
-      Reviewers: true
+      Reviewers: {
+        select: {
+          Id: true,
+          Name: true,
+          Email: true
+        }
+      }
     }
   });
   if (!project) return error(400);
+
+  const organization = await prisma.organizations.findUnique({
+    where: {
+      Id: project.Organization.Id
+    },
+    select: {
+      OrganizationStores: {
+        select: {
+          Store: {
+            select: {
+              Id: true,
+              Name: true,
+              Description: true,
+              StoreTypeId: true
+            }
+          }
+        }
+      }
+    }
+  })
+
   const transitions = await prisma.productTransitions.findMany({
     where: {
       ProductId: {
@@ -92,17 +163,43 @@ export const load = (async ({ locals, params }) => {
     where: {
       GroupMemberships: {
         some: {
-          GroupId: project?.GroupId
+          GroupId: project?.Group.Id
         }
       },
       UserRoles: {
         some: {
-          OrganizationId: project?.OrganizationId,
+          OrganizationId: project?.Organization.Id,
           RoleId: RoleId.Author
         }
       }
     }
   });
+
+  const productDefinitions = (await prisma.organizationProductDefinitions.findMany({
+    where: {
+      OrganizationId: project.Organization.Id,
+      ProductDefinition: {
+        ApplicationTypes: project.ApplicationType
+      }
+    },
+    select: {
+      ProductDefinition: {
+        select: {
+          Id: true,
+          Name: true,
+          Description: true,
+          Workflow: {
+            select: {
+              StoreTypeId: true
+            }
+          }
+        }
+      }
+    }
+  })).map((pd) => pd.ProductDefinition);
+
+  const projectProductDefinitionIds = project.Products.map((p) => p.ProductDefinition.Id);
+
   const authorForm = await superValidate(valibot(addAuthorSchema));
   const reviewerForm = await superValidate({ language: 'en-us' }, valibot(addReviewerSchema));
   return {
@@ -112,10 +209,10 @@ export const load = (async ({ locals, params }) => {
         ...product,
         Transitions: transitions.filter((t) => t.ProductId === product.Id),
         PreviousTransition: strippedTransitions.find(
-          (t) => (t[0] ?? t[1]).ProductId === product.Id
+          (t) => (t[0] ?? t[1])?.ProductId === product.Id
         )?.[0],
         ActiveTransition: strippedTransitions.find(
-          (t) => (t[0] ?? t[1]).ProductId === product.Id
+          (t) => (t[0] ?? t[1])?.ProductId === product.Id
         )?.[1]
       }))
     },
@@ -123,7 +220,7 @@ export const load = (async ({ locals, params }) => {
       where: {
         OrganizationMemberships: {
           some: {
-            OrganizationId: project.OrganizationId
+            OrganizationId: project.Organization.Id
           }
         },
         GroupMemberships: {
@@ -135,14 +232,17 @@ export const load = (async ({ locals, params }) => {
     }),
     possibleGroups: await prisma.groups.findMany({
       where: {
-        OwnerId: project.OrganizationId
+        OwnerId: project.Organization.Id
       }
     }),
     authorsToAdd,
     authorForm,
     reviewerForm,
     deleteAuthorForm: await superValidate(valibot(deleteAuthorSchema)),
-    deleteReviewerForm: await superValidate(valibot(deleteReviewerSchema))
+    deleteReviewerForm: await superValidate(valibot(deleteReviewerSchema)),
+    productsToAdd: productDefinitions.filter((pd) => !projectProductDefinitionIds.includes(pd.Id)),
+    addProductForm: await superValidate(valibot(addProductSchema)),
+    stores: organization?.OrganizationStores.map((os) => os.Store) ?? []
   };
 }) satisfies PageServerLoad;
 
@@ -188,48 +288,18 @@ export const actions = {
   async addProduct(event) {
     if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
       return fail(403);
-    // TODO: api and bulltask
     const form = await superValidate(event.request, valibot(addProductSchema));
     if (!form.valid) return fail(400, { form, ok: false });
-    // Appears that CanUpdate is not used TODO
     const productId = await DatabaseWrites.products.create({
       ProjectId: parseInt(event.params.id),
       ProductDefinitionId: form.data.productDefinitionId,
       StoreId: form.data.storeId,
-      StoreLanguageId: form.data.storeLanguageId,
-      WorkflowJobId: form.data.workflowJobId,
-      WorkflowBuildId: form.data.workflowBuildId,
-      WorkflowPublishId: form.data.workflowPublishId
+      WorkflowJobId: 0,
+      WorkflowBuildId: 0,
+      WorkflowPublishId: 0
     });
 
-    if (typeof productId === 'string') {
-      const flow = (
-        await prisma.productDefinitions.findUnique({
-          where: {
-            Id: form.data.productDefinitionId
-          },
-          select: {
-            Workflow: {
-              select: {
-                Id: true,
-                Type: true,
-                ProductType: true,
-                WorkflowOptions: true
-              }
-            }
-          }
-        })
-      )?.Workflow;
-
-      if (flow?.Type === WorkflowType.Startup) {
-        Workflow.create(productId, {
-          productType: flow.ProductType,
-          options: new Set(flow.WorkflowOptions)
-        });
-      }
-    }
-
-    return { form, ok: true };
+    return { form, ok: !!productId };
   },
   async addAuthor(event) {
     if (!verifyCanViewAndEdit((await event.locals.auth())!, parseInt(event.params.id)))
diff --git a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.svelte b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.svelte
index 0ca284cd5..684e10a63 100644
--- a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.svelte
+++ b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/projects/[id=idNumber]/+page.svelte
@@ -36,6 +36,11 @@
       }
     }
   );
+  const {
+    form: addProductForm,
+    enhance: addProductEnhance,
+    submit: addProductSubmit
+  } = superForm(data.addProductForm);
   function openModal(id: string) {
     (window[('modal' + id) as any] as any).showModal();
   }
@@ -61,6 +66,13 @@
       ownerSettingsForm.requestSubmit();
     }, 2000);
   }
+
+  let addProductModal: HTMLDialogElement | undefined;
+  let selectingStore: boolean = false;
+  let selectedProduct: number = 0;
+  $: availableStores = data.stores.filter(
+    (s) => s.StoreTypeId === data.productsToAdd[selectedProduct]?.Workflow.StoreTypeId
+  );
 </script>
 
 <div class="w-full max-w-6xl mx-auto relative">
@@ -115,9 +127,107 @@
             <span class="italic">{m.products_definition()}</span>
           </div>
         </div>
-        <button class="btn btn-outline" on:click={() => alert('TODO api proxy')}>
+        <button
+          class="btn btn-outline"
+          on:click={() => addProductModal?.showModal()}
+          disabled={!data.productsToAdd.length}
+        >
           {m.project_products_add()}
         </button>
+        <dialog bind:this={addProductModal} class="modal">
+          <form class="modal-box" action="?/addProduct" method="POST" use:addProductEnhance>
+            <div class="items-center text-center" class:hidden={selectingStore}>
+              <div class="flex flex-row">
+                <h2 class="text-lg font-bold grow">{m.project_products_popup_addTitle()}</h2>
+                <button
+                  class="btn btn-ghost"
+                  type="button"
+                  on:click={() => {
+                    addProductModal?.close();
+                  }}
+                >
+                  <IconContainer icon="mdi:close" width={36} class="opacity-80" />
+                </button>
+              </div>
+              <hr />
+              <div class="flex flex-col pt-1 space-y-1">
+                {#each data.productsToAdd as productDef, i}
+                  <!-- svelte-ignore a11y-click-events-have-key-events -->
+                  <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
+                  <label
+                    class="flex flex-col border border-secondary rounded text-left form-control cursor-pointer"
+                    on:click={() => {
+                      selectingStore = true;
+                      selectedProduct = i;
+                    }}
+                  >
+                    <div class="flex flex-row bg-neutral-300 p-2 w-full text-black">
+                      <IconContainer icon={getIcon(productDef.Name ?? '')} width="24" />
+                      {productDef.Name}
+                    </div>
+                    <p class="p-2 text-sm text-neutral-400">{productDef.Description}</p>
+                    <input
+                      type="radio"
+                      name="productDefinitionId"
+                      value={productDef.Id}
+                      class="hidden"
+                    />
+                  </label>
+                {/each}
+              </div>
+            </div>
+            <div class="items-center text-center" class:hidden={!selectingStore}>
+              <div class="flex flex-row">
+                <h2 class="text-lg font-bold">
+                  {m.products_storeSelect({
+                    name: data.productsToAdd[selectedProduct]?.Name || ''
+                  })}
+                </h2>
+                <button
+                  class="btn btn-ghost"
+                  type="button"
+                  on:click={() => {
+                    selectingStore = false;
+                  }}
+                >
+                  <IconContainer icon="mdi:close" width={36} class="opacity-80" />
+                </button>
+              </div>
+              <hr />
+              <div class="flex flex-col pt-1 space-y-1">
+                {#if availableStores.length}
+                  {#each availableStores as store}
+                    <!-- svelte-ignore a11y-click-events-have-key-events -->
+                    <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
+                    <label
+                      class="flex flex-col border border-secondary rounded text-left form-control cursor-pointer"
+                    >
+                      <div class="flex flex-row bg-neutral-300 p-2 w-full text-black">
+                        {store.Name}
+                      </div>
+                      <p class="p-2 text-sm text-neutral-400">{store.Description}</p>
+                      <input
+                        type="submit"
+                        name="storeId"
+                        value={store.Id}
+                        class="hidden"
+                        on:click={() => {
+                          addProductModal?.close();
+                          selectingStore = false;
+                        }}
+                      />
+                    </label>
+                  {/each}
+                {:else}
+                  {m.products_noStoresAvailable()}
+                {/if}
+              </div>
+            </div>
+          </form>
+          <form method="dialog" class="modal-backdrop">
+            <button on:click={() => (selectingStore = false)}>close</button>
+          </form>
+        </dialog>
       </div>
       <div>
         {#if !data.project?.Products.length}
@@ -160,10 +270,10 @@
                             {m.project_productFiles()}
                           </a>
                         </li>
-                        {#if data.session?.user.roles.find((role) => role[0] === data.project?.OrganizationId && role[1] === RoleId.OrgAdmin)}
+                        {#if data.session?.user.roles.find((role) => role[0] === data.project?.Organization.Id && role[1] === RoleId.OrgAdmin)}
                           <li class="w-full rounded-none">
                             <span class="text-nowrap">
-                              <!-- TODO: what is this -->
+                              <!-- TODO: figure out Publishing Properties -->
                               {m.project_products_popup_properties()}
                             </span>
                           </li>
@@ -202,6 +312,7 @@
                 {m.tasks_forNames({
                   allowedNames: product.ActiveTransition?.AllowedUserNames || m.appName(),
                   activityName: product.ActiveTransition?.InitialState ?? ''
+                  // activityName appears to show up blank primarily at the very startup of a new product?
                 })}
                 {#if product.UserTasks.slice(-1)[0]?.UserId === $page.data.session?.user.userId}
                   <a class="link mx-2" href="/tasks/{product.Id}">
@@ -209,7 +320,7 @@
                   </a>
                 {/if}
               </div>
-              <ProductDetails product={product} />
+              <ProductDetails {product} />
             </div>
           {/each}
         {/if}
@@ -283,7 +394,7 @@
                 {m.project_side_organization()}
               </span>
               <span class="text-right">
-                {data.organizations.find((o) => data.project?.OrganizationId === o.Id)?.Name}
+                {data.organizations.find((o) => data.project?.Organization.Id === o.Id)?.Name}
               </span>
             </div>
             <div class="divider my-2" />
@@ -310,7 +421,7 @@
                     <input
                       type="hidden"
                       name="owner"
-                      value={data.project.OwnerId}
+                      value={data.project.Owner.Id}
                       bind:this={ownerField}
                     />
                     <ul class="menu menu-compact overflow-hidden rounded-md">
@@ -318,7 +429,7 @@
                         <li class="w-full rounded-none">
                           <button
                             class="text-nowrap"
-                            class:font-bold={owner.Id === data.project.OwnerId}
+                            class:font-bold={owner.Id === data.project.Owner.Id}
                             on:click={() => {
                               ownerField.value = owner.Id + '';
                               submitOwnerSettingsForm();
@@ -357,7 +468,7 @@
                     <input
                       type="hidden"
                       name="group"
-                      value={data.project.GroupId}
+                      value={data.project.Group.Id}
                       bind:this={groupField}
                     />
                     <ul class="menu menu-compact overflow-hidden rounded-md">
@@ -365,7 +476,7 @@
                         <li class="w-full rounded-none">
                           <button
                             class="text-nowrap"
-                            class:font-bold={group.Id === data.project.GroupId}
+                            class:font-bold={group.Id === data.project.Group.Id}
                             on:click={() => {
                               groupField.value = group.Id + '';
                               submitOwnerSettingsForm();
@@ -544,4 +655,8 @@
     /* grid-template-rows: min-content min-content min-content; */
     column-gap: 0.75rem;
   }
+  /* source: https://github.com/saadeghi/daisyui/issues/3040#issuecomment-2250530354 */
+  :root:has(:is(.modal-open, .modal:target, .modal-toggle:checked + .modal, .modal[open])) {
+    scrollbar-gutter: unset;
+  }
 </style>
diff --git a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/+page.svelte b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/+page.svelte
index 3d9126655..cedbf1d47 100644
--- a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/+page.svelte
+++ b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/+page.svelte
@@ -53,7 +53,29 @@
             </tr>
             {#if task.Comment}
               <tr>
-                <div class="pl-7">{task.Comment}</div>
+                <div class="pl-7">
+                  {#if task.Comment.startsWith('system.')}
+                    {#if task.Comment.startsWith('system.build-failed')}
+                      <span>
+                        {m.system_buildFailed()}
+                      </span>
+                    {:else if task.Comment.startsWith('system.publish-failed')}
+                      <span>
+                        {m.system_publishFailed()}
+                      </span>
+                    {/if}
+                    <br />
+                    <a
+                      class="link link-info"
+                      href={task.Comment.replace(/system\.(build|publish)-failed,/, '')}
+                      target="_blank"
+                    >
+                      {m.project_products_publications_console()}
+                    </a>
+                  {:else}
+                    {task.Comment}
+                  {/if}
+                </div>
               </tr>
             {/if}
           {/each}
diff --git a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/[product_id]/+page.server.ts b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/[product_id]/+page.server.ts
index 51d229fb1..c48ef06e5 100644
--- a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/[product_id]/+page.server.ts
+++ b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/[product_id]/+page.server.ts
@@ -57,7 +57,6 @@ export const load = (async ({ params, url, locals }) => {
           Reviewers: snap?.context.includeReviewers
             ? {
               select: {
-                Id: true,
                 Name: true,
                 Email: true
               }
@@ -122,11 +121,9 @@ export const load = (async ({ params, url, locals }) => {
               : undefined //include all
       },
       select: {
-        ProductBuildId: true,
         ArtifactType: true,
         FileSize: true,
-        Url: true,
-        Id: true
+        Url: true
       }
     })
     : [];
@@ -196,8 +193,12 @@ export const actions = {
       });
     }
 
-    redirect(302, '/tasks'); // keep the redirect for now. It takes a bit to update the db.
-    //TODO: maybe switch to just a page reload to show the new instructions once we use sockets? As it is right now, the `waiting` instructions will never be shown because of this redirect.
+    const product = await prisma.products.findUnique({
+      where: { Id: params.product_id },
+      select: { ProjectId: true }
+    });
+
+    redirect(302, `/projects/${product?.ProjectId}`);
   }
 } satisfies Actions;
 
diff --git a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/[product_id]/+page.svelte b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/[product_id]/+page.svelte
index 22718c93c..cd51faaf4 100644
--- a/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/[product_id]/+page.svelte
+++ b/source/SIL.AppBuilder.Portal/src/routes/(authenticated)/tasks/[product_id]/+page.svelte
@@ -1,10 +1,11 @@
 <script lang="ts">
-  import type { PageData } from './$types';
-  import { instructions } from './instructions';
+  import IconContainer from '$lib/components/IconContainer.svelte';
   import SortTable from '$lib/components/SortTable.svelte';
-  import { superForm } from 'sveltekit-superforms';
   import * as m from '$lib/paraglide/messages';
   import { bytesToHumanSize } from '$lib/utils';
+  import { superForm } from 'sveltekit-superforms';
+  import type { PageData } from './$types';
+  import { instructions } from './instructions';
 
   export let data: PageData;
   const { form, enhance, submit } = superForm(data.taskForm, {
@@ -14,6 +15,7 @@
       }
     }
   });
+  let urlCopied = false;
 </script>
 
 <div class="p-5">
@@ -30,7 +32,7 @@
     {#if data.actions?.length}
       <div class="flex flex-row gap-x-3">
         {#each data.actions as action}
-          <label class="btn">
+          <label class="btn btn-primary">
             {action}<!-- TODO: i18n (after MVP) -->
             <input
               type="radio"
@@ -139,12 +141,27 @@
         <div class="label">
           <span class="label-text">{m.tasks_appProjectURL()}</span>
         </div>
-        <input
-          type="text"
-          class="input input-bordered w-full"
-          readonly
-          value={data.fields.projectURL}
-        />
+        <span class="input input-bordered w-full flex flex-row gap-2 items-center">
+          <input type="text" class="grow" readonly value={data.fields.projectURL} />
+          <button
+            class="cursor-copy"
+            on:click={() => {
+              if (data.fields.projectURL) {
+                navigator.clipboard.writeText(data.fields.projectURL);
+                urlCopied = true;
+                setTimeout(() => {
+                  urlCopied = false;
+                }, 5000);
+              }
+            }}
+          >
+            {#if urlCopied}
+              <IconContainer icon="mdi:check" width={24} class="text-success" />
+            {:else}
+              <IconContainer icon="mdi:content-copy" width={24} />
+            {/if}
+          </button>
+        </span>
       </label>
     {/if}
     {#if data.fields.displayProductDescription && data.fields.appType && data.fields.projectLanguageCode}
@@ -195,11 +212,6 @@
         className="max-h-96"
         data={data.files}
         columns={[
-          {
-            id: 'buildId',
-            header: m.tasks_files_buildId(),
-            data: (f) => f.ProductBuildId
-          },
           {
             id: 'artifactType',
             header: m.project_type(),
@@ -217,15 +229,12 @@
             id: 'url',
             header: m.tasks_files_link(),
             data: (f) => f.Url,
-            render: (u) => `<a class="link" href="${u}">${u}</a>`
-          },
-          {
-            id: 'fileId',
-            header: m.tasks_files_fileId(),
-            data: (f) => f.Id,
-            sortable: true
+            render: (u) => `<a class="link" href="${u}" target="_blank">${u}</a>`
           }
         ]}
+        onRowClick={(data) => {
+          window.open(data.Url, '_blank')?.focus();
+        }}
       />
     </div>
   {/if}
@@ -236,12 +245,6 @@
         className="max-h-96"
         data={data.reviewers}
         columns={[
-          {
-            id: 'id',
-            header: m.common_id(),
-            data: (r) => r.Id,
-            sortable: true
-          },
           {
             id: 'name',
             header: m.common_name(),